@unson/brainbase-mcp 0.2.3 → 0.3.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Brainbaseは、自分が承認した仕事の前提をCodex、Claude Code、Code
4
4
 
5
5
  最初の目標は情報源をすべて接続することではありません。自分、仕事、関係者、判断基準の最小文脈を保存し、10分以内に「同じ前提を説明し直さず役立つ出力」を確認することです。
6
6
 
7
- Ontology 1.0.0は、ローカルファイルへ持ち運べる意味契約を追加します。ホスト型Brainbaseを必要とせず、型、関係語彙、検証制約、決定論的な判断推論、バージョン移行を定義します。
7
+ Ontology 2.0.0は、ローカルファイルへ持ち運べる意味契約に、Relation Registryで管理する正規エンティティ間のIDエッジを追加します。ホスト型Brainbaseを必要とせず、型、関係語彙、検証制約、決定論的な判断推論、バージョン移行を定義します。履歴解釈として0.0.0と1.0.0も選択できます。
8
8
 
9
9
  このリポジトリに、社内BrainbaseのUI、セッション実行基盤、xterm転送、ワークフロー管制、SNS運用、ホスト型バックエンド、Infisical設定、雲孫の社内データは含みません。それらは社内版`brainbase-unson`の範囲です。
10
10
 
@@ -35,12 +35,12 @@ brainbase onboard:start --target codex
35
35
  # 表示された onboard:seed を確認して実行
36
36
  brainbase onboard:install --target codex --dry-run
37
37
  # 設定を承認・反映し、Codexを再起動
38
- # 新しいCodexでBrainbaseのget_context/searchを使って実際の依頼を試す
38
+ # 新しいCodexでBrainbaseのresolve_entity/get_context/searchを使って実際の依頼を試す
39
39
  ```
40
40
 
41
41
  リポジトリをcloneした場合も、`npm run onboard:start -- --target codex`から同じ順序で進めます。
42
42
 
43
- 利用者がBrainbaseの導入を依頼したら、エージェントはチェックリストを返すだけでなく、この公開CLIを実行します。承認された最小文脈を保存し、MCP設定を反映した新しい実エージェントで`get_context`と`search`を使って現実の依頼へ回答します。その実回答を見た本人が「役立った」と判断して初めて初回価値です。`ready: true`、`cli_sample_ready`、CLIの処理時間、合成ペルソナ評価、Skillsやルーティンの生成、`onboard:install --dry-run`だけでは導入完了ではありません。
43
+ 利用者がBrainbaseの導入を依頼したら、エージェントはチェックリストを返すだけでなく、この公開CLIを実行します。承認された最小文脈を保存し、MCP設定を反映した新しい実エージェントで`resolve_entity`、`get_context`、`search`を使って現実の依頼へ回答します。その実回答を見た本人が「役立った」と判断して初めて初回価値です。`ready: true`、`cli_sample_ready`、CLIの処理時間、合成ペルソナ評価、Skillsやルーティンの生成、`onboard:install --dry-run`だけでは導入完了ではありません。
44
44
 
45
45
  For a Google Workspace / Google Drive / local-notes setup, pass the known answers and let the command surface what still needs approval:
46
46
 
@@ -91,7 +91,7 @@ Optionally preview the saved context locally. This is not an onboarding completi
91
91
  brainbase onboard:demo --scenario "Draft the first note I should send to Key Partner about Current project"
92
92
  ```
93
93
 
94
- `onboard:demo` reads only locally saved, approved facts. It does not call an LLM, an agent, or a hosted backend. Its result is only a preview. Continue through MCP installation, restart the selected agent, make a real request using `get_context` and `search`, and ask the user whether that actual answer was useful.
94
+ `onboard:demo` reads only locally saved, approved facts. It does not call an LLM, an agent, or a hosted backend. Its result is only a preview. Continue through MCP installation, restart the selected agent, make a real request using `resolve_entity`, `get_context`, and `search`, and ask the user whether that actual answer was useful.
95
95
 
96
96
  After the demo, keep onboarding open: the preview is not the first-value gate. Continue until a real agent uses Brainbase and the human user confirms that the result was useful.
97
97
 
@@ -100,10 +100,10 @@ After seed, install and verify MCP before asking for the human value judgment:
100
100
  ```bash
101
101
  brainbase onboard:install --target codex --dry-run
102
102
  brainbase doctor
103
- # restart Codex, use get_context/search for the real request, then ask whether it was useful
103
+ # restart Codex, use resolve_entity/get_context/search for the real request, then ask whether it was useful
104
104
  ```
105
105
 
106
- The recommended order is public skills, `ohayo` / `oyasumi` / `retro` routines registered paused or confirmation-gated, real MCP config merge after approving the dry-run snippet, source allowlist / import / candidate review decisions, then `doctor` plus MCP `get_context` / `search` verification from a fresh agent session.
106
+ The recommended order is public skills, `ohayo` / `oyasumi` / `retro` routines registered paused or confirmation-gated, real MCP config merge after approving the dry-run snippet, source allowlist / import / candidate review decisions, then `doctor` plus MCP `resolve_entity` / `get_context` / `search` verification from a fresh agent session.
107
107
 
108
108
  The commands above are still safe by default. `onboard:skills` and `onboard:routines` generate output unless you provide an explicit `--out`, and `onboard:install --dry-run` is only a preview. Do not treat those generated artifacts as installed until the user approves file writes, scheduler registration, and live config changes.
109
109
 
@@ -290,7 +290,7 @@ The default data directory is:
290
290
 
291
291
  It contains the canonical local SSOT:
292
292
 
293
- - `graph.json`: people, organizations, projects, and relationship entities.
293
+ - `graph.json`: canonical people, organizations, projects, and decisions, plus typed stable-ID edges between them.
294
294
  - `personal-kg.jsonl`: values, judgment criteria, experiences, and personal context.
295
295
  - `relationships.json`: relationship context that should survive across tools.
296
296
  - `decisions.jsonl`: decision records and principles.
@@ -319,14 +319,15 @@ BRAINBASE_PERSONAL_OS_DIR=/path/to/personal-os brainbase-mcp
319
319
  - `get_context`: returns initial AI context from the local Graph and Personal KG.
320
320
  - `list_entities`: lists `person`, `org`, `project`, `relationship`, and `decision` entities.
321
321
  - `search`: searches canonical Graph and Personal KG data.
322
+ - `resolve_entity`: resolves mentions in text to canonical Graph v2 IDs and returns a privacy-safe evidence receipt.
322
323
  - `search_personal_kg`: searches owner-local Personal KG only.
323
324
  - `onboarding_status`: reports seeded areas, first value demo readiness, missing setup, and local connection status.
324
- - `get_ontology`: returns the immutable bundled Ontology 1.0.0 release without reading Personal OS files.
325
+ - `get_ontology`: returns the immutable bundled active Ontology 2.0.0 release without reading Personal OS files.
325
326
  - `audit_ontology`: audits canonical local files and distinguishes verified violations from unavailable input.
326
327
  - `infer_decisions`: derives active, superseded, and conflicting decisions from explicit rules.
327
328
  - `ontology_impact`: explains compatibility, migration, and rollback from an earlier ontology version.
328
329
 
329
- ## Portable Ontology 1.0.0
330
+ ## Portable Ontology 2.0.0
330
331
 
331
332
  Inspect the semantic contract and audit your local canonical files:
332
333
 
@@ -334,14 +335,18 @@ Inspect the semantic contract and audit your local canonical files:
334
335
  brainbase ontology:show
335
336
  brainbase ontology:audit
336
337
  brainbase ontology:audit --ontology-version 0.0.0
338
+ brainbase ontology:audit --ontology-version 1.0.0
339
+ brainbase ontology:migrate
340
+ # previewのexpectedInputDigestを確認してから適用
341
+ brainbase ontology:migrate --write --expected-input-digest '<previewの値>'
337
342
  ```
338
343
 
339
344
  `ontology:audit` exits non-zero when an error-level violation exists or when a canonical file cannot be verified. It never reports an unavailable or malformed source as zero violations. Warnings, such as a relationship whose person is not yet present in the Graph, remain visible but do not block approved writes.
340
- Use `--ontology-version 0.0.0` to interpret a pre-kernel snapshot without retroactively applying the 1.0.0 `effectiveAt`, supersession, conflict, or validation rules. The selected version is included in audit and inference results; unsupported versions fail explicitly.
345
+ Use `--ontology-version 0.0.0` to interpret a pre-kernel snapshot without retroactively applying the later `effectiveAt`, supersession, conflict, or validation rules. Use `--ontology-version 1.0.0` for the immutable first portable release. When the flag is omitted, Graph v2 uses its recorded ontology binding; legacy Graph data uses the active 2.0.0 release. The selected version is included in audit and inference results; unsupported versions fail explicitly.
341
346
 
342
347
  Decision evolution is opt-in, read-compatible, and write-gated. Existing decision rows remain readable. New rows may add `topic`, `supersedes`, and `effectiveAt`; only an explicit `supersedes` reference makes an older decision inactive. Multiple active decisions with the same explicit `topic` are reported as a conflict instead of being silently resolved.
343
348
 
344
- Before enabling 1.0.0 writes, back up the Personal OS directory, capture the current MCP client configuration and launch command, and run the read-only `brainbase ontology:audit --ontology-version 1.0.0`. Existing rows remain readable, but error-level semantic violations must be reviewed before `onboard:seed`, `onboard:projects --write`, or `onboard:apply --write` can change canonical files. For the first npm release, rollback means running `npm uninstall -g @unson/brainbase-mcp`, restoring the captured MCP client configuration and launch command, and restarting the client. For later upgrades, reinstall the last known working package version instead. Restore the pre-upgrade Personal OS backup only if reviewed repairs changed canonical files.
349
+ Before enabling 2.0.0 writes, back up the Personal OS directory, run the read-only historical audit when upgrading from 1.0.0, and preview `brainbase ontology:migrate`. Apply the migration only with the preview's `expectedInputDigest`; a concurrent input change blocks the write. Existing rows remain readable, but error-level semantic violations must be reviewed before a canonical write. Roll back by restoring the pre-migration backup and reinstalling the recorded last known working package version.
345
350
 
346
351
  ## CLI
347
352
 
@@ -505,7 +510,7 @@ Keep or pin the internal `brainbase-unson` system when you need:
505
510
  - Legacy Graph API MCP tools such as `get_entity`.
506
511
  - VibePro runtime or internal 31013 operation surfaces.
507
512
 
508
- The v1 MCP surface contains the five original context/onboarding tools plus the additive Ontology 1.0.0 tools: `get_ontology`, `audit_ontology`, `infer_decisions`, and `ontology_impact`.
513
+ The original MCP surface remains compatible and adds the Ontology tools plus `resolve_entity`. The active Ontology release is 2.0.0; 0.0.0 and 1.0.0 remain available for historical interpretation.
509
514
 
510
515
  ## Hosted Backends
511
516
 
@@ -0,0 +1,11 @@
1
+ import type { CanonicalEdge, CanonicalEntity, CanonicalGraphFile, GraphFileV2 } from './types.js';
2
+ export interface CanonicalWriteSet {
3
+ entities: CanonicalEntity[];
4
+ edges: CanonicalEdge[];
5
+ }
6
+ /**
7
+ * Apply explicit canonical writes without deriving relations from labels or free text.
8
+ * Existing records with other IDs keep their order and content; matching IDs are updated in place.
9
+ */
10
+ export declare function applyCanonicalWrites(graph: CanonicalGraphFile, writes: CanonicalWriteSet): GraphFileV2;
11
+ export declare function buildCanonicalEdge(edge: Omit<CanonicalEdge, 'id'>): CanonicalEdge;
@@ -0,0 +1,35 @@
1
+ import { canonicalEdgeId, validateCanonicalGraph } from './canonical-graph.js';
2
+ /**
3
+ * Apply explicit canonical writes without deriving relations from labels or free text.
4
+ * Existing records with other IDs keep their order and content; matching IDs are updated in place.
5
+ */
6
+ export function applyCanonicalWrites(graph, writes) {
7
+ if (graph.version !== 2) {
8
+ throw new Error('migration_required: Graph v1 cannot store canonical ID edges; migrate graph.json to Graph v2 before writing');
9
+ }
10
+ const next = {
11
+ ...graph,
12
+ entities: upsertById(graph.entities, writes.entities),
13
+ edges: upsertById(graph.edges, writes.edges)
14
+ };
15
+ validateCanonicalGraph(next);
16
+ return next;
17
+ }
18
+ export function buildCanonicalEdge(edge) {
19
+ return { ...edge, id: canonicalEdgeId(edge) };
20
+ }
21
+ function upsertById(existing, additions) {
22
+ const result = [...existing];
23
+ const indexById = new Map(result.map((record, index) => [record.id, index]));
24
+ for (const addition of additions) {
25
+ const index = indexById.get(addition.id);
26
+ if (index === undefined) {
27
+ indexById.set(addition.id, result.length);
28
+ result.push(addition);
29
+ }
30
+ else {
31
+ result[index] = addition;
32
+ }
33
+ }
34
+ return result;
35
+ }
@@ -0,0 +1,9 @@
1
+ import type { CanonicalEdge, CanonicalGraphFile } from './types.js';
2
+ export declare function canonicalEdgeId(edge: Pick<CanonicalEdge, 'fromId' | 'relation' | 'toId'>): string;
3
+ export declare function validateCanonicalGraph(graph: unknown, options?: {
4
+ allowDuplicateEntityIds?: boolean;
5
+ }): asserts graph is CanonicalGraphFile;
6
+ export declare function isActiveAt(record: {
7
+ validFrom?: string;
8
+ validTo?: string;
9
+ }, asOf: string): boolean;
@@ -0,0 +1,174 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { getCanonicalRelation } from './relation-registry.js';
3
+ export function canonicalEdgeId(edge) {
4
+ return `edge-${createHash('sha256').update(JSON.stringify([edge.fromId, edge.relation, edge.toId])).digest('hex').slice(0, 24)}`;
5
+ }
6
+ const v1EntityKinds = new Set(['person', 'org', 'project', 'relationship']);
7
+ const canonicalEntityKinds = new Set(['person', 'org', 'project', 'decision']);
8
+ export function validateCanonicalGraph(graph, options = {}) {
9
+ if (!graph || typeof graph !== 'object' || !('version' in graph) || !('entities' in graph)) {
10
+ throw new Error('GRAPH-SHAPE-VALID: graph must be an object with version and entities');
11
+ }
12
+ const candidate = graph;
13
+ if (candidate.version !== 1 && candidate.version !== 2) {
14
+ throw new Error(`GRAPH-VERSION-SUPPORTED: unsupported graph version ${String(candidate.version)}`);
15
+ }
16
+ if (!Array.isArray(candidate.entities)) {
17
+ throw new Error('GRAPH-SHAPE-VALID: entities must be an array');
18
+ }
19
+ const entityIds = new Set();
20
+ for (const [index, entityValue] of candidate.entities.entries()) {
21
+ if (!entityValue || typeof entityValue !== 'object') {
22
+ throw new Error(`GRAPH-ENTITY-SHAPE at entities[${index}]: entity must be an object`);
23
+ }
24
+ const entity = entityValue;
25
+ assertNonEmptyString(entity.id, `entities[${index}].id`);
26
+ assertNonEmptyString(entity.name, `entities[${index}].name`);
27
+ const allowedKinds = candidate.version === 1 ? v1EntityKinds : canonicalEntityKinds;
28
+ if (typeof entity.type !== 'string' || !allowedKinds.has(entity.type)) {
29
+ throw new Error(`GRAPH-ENTITY-TYPE at entities[${index}].type: unsupported entity type ${String(entity.type)}`);
30
+ }
31
+ if (entity.aliases !== undefined && (!Array.isArray(entity.aliases) || entity.aliases.some((alias) => typeof alias !== 'string' || alias.trim() === ''))) {
32
+ throw new Error(`GRAPH-ENTITY-ALIASES at entities[${index}].aliases: aliases must be non-empty strings`);
33
+ }
34
+ assertOptionalString(entity.summary, `entities[${index}].summary`);
35
+ assertOptionalStringArray(entity.tags, `entities[${index}].tags`);
36
+ assertOptionalRecord(entity.metadata, `entities[${index}].metadata`);
37
+ if (entityIds.has(entity.id)) {
38
+ if (!options.allowDuplicateEntityIds) {
39
+ throw new Error(`GRAPH-ENTITY-ID-UNIQUE at entities[${index}].id: duplicate canonical entity ID ${entity.id}`);
40
+ }
41
+ }
42
+ entityIds.add(entity.id);
43
+ }
44
+ if (candidate.owner !== undefined) {
45
+ assertOptionalRecord(candidate.owner, 'owner');
46
+ const owner = candidate.owner;
47
+ assertOptionalString(owner.id, 'owner.id');
48
+ assertOptionalString(owner.name, 'owner.name');
49
+ assertOptionalString(owner.summary, 'owner.summary');
50
+ }
51
+ if (candidate.version === 1)
52
+ return;
53
+ if (!candidate.ontology || typeof candidate.ontology !== 'object') {
54
+ throw new Error('GRAPH-ONTOLOGY-REQUIRED: Graph v2 requires ontology binding');
55
+ }
56
+ const ontology = candidate.ontology;
57
+ if (ontology.id !== 'brainbase-personal-os') {
58
+ throw new Error('GRAPH-ONTOLOGY-ID: Graph v2 ontology.id must be brainbase-personal-os');
59
+ }
60
+ assertNonEmptyString(ontology.version, 'ontology.version');
61
+ assertNonEmptyString(ontology.releaseDigest, 'ontology.releaseDigest');
62
+ if (!Array.isArray(candidate.edges)) {
63
+ throw new Error('GRAPH-SHAPE-VALID: Graph v2 edges must be an array');
64
+ }
65
+ const typedGraph = graph;
66
+ const entities = new Map();
67
+ for (const [index, entity] of typedGraph.entities.entries()) {
68
+ assertInterval(entity.validFrom, entity.validTo, `entities[${index}]`);
69
+ entities.set(entity.id, entity);
70
+ }
71
+ const edgeIds = new Set();
72
+ const tuples = new Set();
73
+ for (const [index, edge] of typedGraph.edges.entries()) {
74
+ if (!edge || typeof edge !== 'object') {
75
+ throw new Error(`GRAPH-EDGE-SHAPE at edges[${index}]: edge must be an object`);
76
+ }
77
+ assertNonEmptyString(edge.id, `edges[${index}].id`);
78
+ assertNonEmptyString(edge.fromId, `edges[${index}].fromId`);
79
+ assertNonEmptyString(edge.relation, `edges[${index}].relation`);
80
+ assertNonEmptyString(edge.toId, `edges[${index}].toId`);
81
+ assertOptionalString(edge.role, `edges[${index}].role`);
82
+ assertOptionalString(edge.context, `edges[${index}].context`);
83
+ if (edge.provenance !== undefined) {
84
+ assertOptionalRecord(edge.provenance, `edges[${index}].provenance`);
85
+ if (!['user_approved', 'migration', 'import', 'onboarding'].includes(String(edge.provenance.sourceKind))) {
86
+ throw new Error(`GRAPH-EDGE-PROVENANCE at edges[${index}].provenance.sourceKind: unsupported source kind`);
87
+ }
88
+ assertOptionalString(edge.provenance.sourceId, `edges[${index}].provenance.sourceId`);
89
+ assertOptionalString(edge.provenance.evidenceHash, `edges[${index}].provenance.evidenceHash`);
90
+ }
91
+ if (edgeIds.has(edge.id)) {
92
+ throw new Error(`GRAPH-EDGE-ID-UNIQUE at edges[${index}].id: duplicate canonical edge ID ${edge.id}`);
93
+ }
94
+ edgeIds.add(edge.id);
95
+ const tuple = JSON.stringify([edge.fromId, edge.relation, edge.toId]);
96
+ if (tuples.has(tuple)) {
97
+ throw new Error(`GRAPH-EDGE-TUPLE-UNIQUE at edges[${index}]: duplicate canonical edge ${tuple}`);
98
+ }
99
+ tuples.add(tuple);
100
+ const from = entities.get(edge.fromId);
101
+ const to = entities.get(edge.toId);
102
+ if (!from || !to) {
103
+ throw new Error(`GRAPH-EDGE-ENDPOINT-EXISTS at edges[${index}]: missing endpoint for ${tuple}`);
104
+ }
105
+ const expected = getCanonicalRelation(edge.relation);
106
+ if (from.type !== expected.from || to.type !== expected.to) {
107
+ throw new Error(`GRAPH-EDGE-ENDPOINT-TYPE at edges[${index}]: ${edge.relation} requires ${expected.from} -> ${expected.to}`);
108
+ }
109
+ if (edge.id !== canonicalEdgeId(edge)) {
110
+ throw new Error(`GRAPH-EDGE-ID-STABLE at edges[${index}].id: expected ${canonicalEdgeId(edge)}`);
111
+ }
112
+ assertInterval(edge.validFrom, edge.validTo, `edges[${index}]`);
113
+ }
114
+ }
115
+ export function isActiveAt(record, asOf) {
116
+ const instant = parseRfc3339(asOf, 'asOf');
117
+ const from = record.validFrom === undefined ? undefined : parseRfc3339(record.validFrom, 'validFrom');
118
+ const to = record.validTo === undefined ? undefined : parseRfc3339(record.validTo, 'validTo');
119
+ return (from === undefined || from <= instant) && (to === undefined || instant < to);
120
+ }
121
+ function assertInterval(validFrom, validTo, path) {
122
+ const from = validFrom === undefined ? undefined : parseRfc3339(validFrom, `${path}.validFrom`);
123
+ const to = validTo === undefined ? undefined : parseRfc3339(validTo, `${path}.validTo`);
124
+ if (from !== undefined && to !== undefined && from > to) {
125
+ throw new Error(`GRAPH-VALIDITY-ORDER at ${path}: validFrom must not be after validTo`);
126
+ }
127
+ }
128
+ function parseRfc3339(value, path) {
129
+ if (typeof value !== 'string') {
130
+ throw new Error(`GRAPH-VALIDITY-DATETIME at ${path}: validity must use RFC 3339 date-time values`);
131
+ }
132
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/u.exec(value);
133
+ if (!match) {
134
+ throw new Error(`GRAPH-VALIDITY-DATETIME at ${path}: validity must use RFC 3339 date-time values`);
135
+ }
136
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, offsetHourText, offsetMinuteText] = match;
137
+ const year = Number(yearText);
138
+ const month = Number(monthText);
139
+ const day = Number(dayText);
140
+ const hour = Number(hourText);
141
+ const minute = Number(minuteText);
142
+ const second = Number(secondText);
143
+ const offsetHour = Number(offsetHourText ?? 0);
144
+ const offsetMinute = Number(offsetMinuteText ?? 0);
145
+ const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
146
+ if (day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) {
147
+ throw new Error(`GRAPH-VALIDITY-DATETIME at ${path}: validity must use a real RFC 3339 date-time value`);
148
+ }
149
+ const parsed = Date.parse(value);
150
+ if (!Number.isFinite(parsed)) {
151
+ throw new Error(`GRAPH-VALIDITY-DATETIME at ${path}: validity must use RFC 3339 date-time values`);
152
+ }
153
+ return parsed;
154
+ }
155
+ function assertNonEmptyString(value, path) {
156
+ if (typeof value !== 'string' || value.trim() === '') {
157
+ throw new Error(`GRAPH-NONEMPTY-STRING at ${path}: value must be a non-empty string`);
158
+ }
159
+ }
160
+ function assertOptionalString(value, path) {
161
+ if (value !== undefined && typeof value !== 'string') {
162
+ throw new Error(`GRAPH-OPTIONAL-STRING at ${path}: value must be a string when present`);
163
+ }
164
+ }
165
+ function assertOptionalStringArray(value, path) {
166
+ if (value !== undefined && (!Array.isArray(value) || value.some((item) => typeof item !== 'string'))) {
167
+ throw new Error(`GRAPH-STRING-ARRAY at ${path}: value must be an array of strings when present`);
168
+ }
169
+ }
170
+ function assertOptionalRecord(value, path) {
171
+ if (value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value))) {
172
+ throw new Error(`GRAPH-OBJECT at ${path}: value must be an object when present`);
173
+ }
174
+ }
package/dist/cli.js CHANGED
@@ -3,7 +3,8 @@ import { constants, realpathSync } from 'node:fs';
3
3
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { delimiter, dirname, isAbsolute, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { initializePersonalOs, loadPersonalOs, mutatePersonalOs } from './ssot.js';
6
+ import { initializePersonalOs, loadPersonalOs, migrateCanonicalGraph, mutatePersonalOs } from './ssot.js';
7
+ import { diagnoseGraph } from './graph-diagnosis.js';
7
8
  import { resolveDataDir } from './paths.js';
8
9
  import { auditPersonalOsDirectory } from './ontology-ssot.js';
9
10
  import { portableOntology, resolveOntologyVersion } from './ontology.js';
@@ -15,6 +16,7 @@ import { assertPublicSafeSkillBundle, buildSkillBundle, parseSkillIds, parseSkil
15
16
  import { buildProjectRegistrationPlan, parseProjectSource, parseProjectStakeholder, renderProjectRegistrationMarkdown } from './projects.js';
16
17
  import { renderGuidedFirstRun } from './guided-onboarding.js';
17
18
  import { blockedJudgmentOutput, processJudgmentHook } from './judgment-host.js';
19
+ import { applyCanonicalWrites, buildCanonicalEdge } from './canonical-edge-builder.js';
18
20
  export async function runCli(argv = process.argv.slice(2), io = process) {
19
21
  const parsed = parseArgs(argv);
20
22
  try {
@@ -60,6 +62,8 @@ export async function runCli(argv = process.argv.slice(2), io = process) {
60
62
  return 0;
61
63
  case 'ontology:audit':
62
64
  return await ontologyAudit(parsed, io);
65
+ case 'ontology:migrate':
66
+ return await ontologyMigrate(parsed, io);
63
67
  case 'judgment:hook':
64
68
  return await judgmentHook(io);
65
69
  case 'judgment:install':
@@ -166,10 +170,9 @@ async function onboardSeed(parsed, io) {
166
170
  const personalEntries = [...os.personalKg];
167
171
  const decisions = [...os.decisions];
168
172
  const relationships = [...os.relationships.relationships];
169
- const graphEntities = [...os.graph.entities];
173
+ const canonicalEntities = [];
170
174
  if (name) {
171
- os.graph.owner = { ...os.graph.owner, name };
172
- upsertGraphEntity(graphEntities, {
175
+ canonicalEntities.push({
173
176
  id: 'self',
174
177
  type: 'person',
175
178
  name,
@@ -194,7 +197,7 @@ async function onboardSeed(parsed, io) {
194
197
  });
195
198
  }
196
199
  for (const project of parsed.values.get('project') ?? []) {
197
- upsertGraphEntity(graphEntities, {
200
+ canonicalEntities.push({
198
201
  id: `project-${hash(project)}`,
199
202
  type: 'project',
200
203
  name: project,
@@ -210,12 +213,20 @@ async function onboardSeed(parsed, io) {
210
213
  });
211
214
  }
212
215
  for (const value of parsed.values.get('decision-principle') ?? []) {
213
- upsertById(decisions, {
216
+ const decision = {
214
217
  id: `decision-${hash(value)}`,
215
218
  title: 'オンボーディングで登録した判断基準',
216
219
  decision: value,
217
220
  tags: ['principle', 'onboarding'],
218
221
  updatedAt: now
222
+ };
223
+ upsertById(decisions, decision);
224
+ canonicalEntities.push({
225
+ id: decision.id,
226
+ type: 'decision',
227
+ name: decision.title,
228
+ summary: decision.decision,
229
+ tags: decision.tags
219
230
  });
220
231
  }
221
232
  for (const encoded of encodedRelationships) {
@@ -228,7 +239,7 @@ async function onboardSeed(parsed, io) {
228
239
  tags: ['relationship'],
229
240
  updatedAt: now
230
241
  });
231
- upsertGraphEntity(graphEntities, {
242
+ canonicalEntities.push({
232
243
  id: `person-${hash(person)}`,
233
244
  type: 'person',
234
245
  name: person,
@@ -236,8 +247,53 @@ async function onboardSeed(parsed, io) {
236
247
  tags: ['relationship']
237
248
  });
238
249
  }
250
+ const projects = (parsed.values.get('project') ?? []).map((project) => ({
251
+ id: `project-${hash(project)}`,
252
+ name: project
253
+ }));
254
+ const people = encodedRelationships.map((encoded) => {
255
+ const [person, role, context] = encoded.split('|').map((part) => part.trim());
256
+ return {
257
+ id: `person-${hash(person)}`,
258
+ relationshipId: `relationship-${hash(encoded)}`,
259
+ role: role || undefined,
260
+ context
261
+ };
262
+ });
263
+ const seedDecisions = (parsed.values.get('decision-principle') ?? []).map((decision) => ({
264
+ id: `decision-${hash(decision)}`,
265
+ decision
266
+ }));
267
+ const canonicalEdges = projects.flatMap((project) => [
268
+ ...(name ? [buildCanonicalEdge({
269
+ fromId: 'self',
270
+ relation: 'participates_in',
271
+ toId: project.id,
272
+ context: 'Registered together during onboarding.',
273
+ provenance: { sourceKind: 'onboarding', sourceId: project.id }
274
+ })] : []),
275
+ ...people.map((person) => buildCanonicalEdge({
276
+ fromId: person.id,
277
+ relation: 'participates_in',
278
+ toId: project.id,
279
+ role: person.role,
280
+ context: person.context,
281
+ provenance: { sourceKind: 'onboarding', sourceId: person.relationshipId }
282
+ })),
283
+ ...seedDecisions.map((decision) => buildCanonicalEdge({
284
+ fromId: decision.id,
285
+ relation: 'governs',
286
+ toId: project.id,
287
+ context: decision.decision,
288
+ provenance: { sourceKind: 'onboarding', sourceId: decision.id }
289
+ }))
290
+ ]);
291
+ const graph = applyCanonicalWrites(os.graph, { entities: canonicalEntities, edges: canonicalEdges });
239
292
  return proposedPersonalOs(os, {
240
- graph: { ...os.graph, entities: graphEntities },
293
+ graph: {
294
+ ...graph,
295
+ owner: { ...graph.owner, ...(name ? { id: 'self', name } : {}) }
296
+ },
241
297
  relationships: { version: 1, relationships },
242
298
  personalKg: personalEntries,
243
299
  decisions
@@ -257,7 +313,7 @@ async function onboardSeed(parsed, io) {
257
313
  '',
258
314
  '次に実行:',
259
315
  `brainbase onboard:install --target codex --dir ${shellArg(dataDir)} --dry-run`,
260
- '設定を承認・反映してエージェントを再起動した後、実際の依頼でBrainbaseのget_contextとsearchを使います。',
316
+ '設定を承認・反映してエージェントを再起動した後、実際の依頼でBrainbaseのresolve_entity、get_context、searchを使います。',
261
317
  ''
262
318
  ];
263
319
  write(io, summary.join('\n'));
@@ -464,21 +520,27 @@ async function onboardProjects(parsed, io) {
464
520
  }
465
521
  async function applyProjectRegistrationPlan(dataDir, plan) {
466
522
  await mutatePersonalOs(dataDir, (os) => {
467
- const graphEntities = [...os.graph.entities];
468
- for (const entity of plan.writes.graphEntities) {
469
- upsertGraphEntity(graphEntities, entity);
470
- }
523
+ const graph = applyCanonicalWrites(os.graph, {
524
+ entities: plan.writes.canonicalEntities,
525
+ edges: plan.writes.canonicalEdges
526
+ });
471
527
  const relationships = [...os.relationships.relationships];
472
528
  for (const relationship of plan.writes.relationships) {
473
- if (!relationships.some((existing) => existing.id === relationship.id)) {
474
- relationships.push(relationship);
475
- }
529
+ upsertById(relationships, relationship);
530
+ }
531
+ const personalKg = [...os.personalKg];
532
+ for (const entry of plan.writes.personalKg) {
533
+ upsertById(personalKg, entry);
534
+ }
535
+ const decisions = [...os.decisions];
536
+ for (const decision of plan.writes.decisions) {
537
+ upsertById(decisions, decision);
476
538
  }
477
539
  return proposedPersonalOs(os, {
478
- graph: { ...os.graph, entities: graphEntities },
540
+ graph,
479
541
  relationships: { version: 1, relationships },
480
- personalKg: [...os.personalKg, ...plan.writes.personalKg],
481
- decisions: [...os.decisions, ...plan.writes.decisions]
542
+ personalKg,
543
+ decisions
482
544
  });
483
545
  });
484
546
  }
@@ -558,25 +620,40 @@ async function onboardApply(parsed, io) {
558
620
  let result;
559
621
  if (willWrite) {
560
622
  await mutatePersonalOs(dataDir, (os) => {
623
+ if (os.graph.version !== 2) {
624
+ throw new Error('migration_required: Graph v1 cannot store canonical ID edges; migrate graph.json to Graph v2 before writing');
625
+ }
561
626
  result = planApply(candidates, { ids: selectedIds, all }, {
562
627
  graphEntities: [...os.graph.entities],
628
+ graphEdges: [...os.graph.edges],
563
629
  relationships: [...os.relationships.relationships],
564
630
  personalKg: os.personalKg,
565
631
  decisions: os.decisions,
566
632
  ownerName: os.graph.owner?.name
567
633
  }, now);
634
+ const personalKg = [...os.personalKg];
635
+ for (const entry of result.personalKgAdditions)
636
+ upsertById(personalKg, entry);
637
+ const decisions = [...os.decisions];
638
+ for (const decision of result.decisionAdditions)
639
+ upsertById(decisions, decision);
640
+ const graph = applyCanonicalWrites(os.graph, result.canonicalWrites);
568
641
  return proposedPersonalOs(os, {
569
- graph: { ...os.graph, owner: result.ownerName ? { ...os.graph.owner, name: result.ownerName } : os.graph.owner, entities: result.graphEntities },
642
+ graph: { ...graph, owner: result.ownerName ? { ...graph.owner, name: result.ownerName } : graph.owner },
570
643
  relationships: { version: 1, relationships: result.relationships },
571
- personalKg: [...os.personalKg, ...result.personalKgAdditions],
572
- decisions: [...os.decisions, ...result.decisionAdditions]
644
+ personalKg,
645
+ decisions
573
646
  });
574
647
  });
575
648
  }
576
649
  else {
577
650
  const os = await loadPersonalOs(dataDir);
651
+ if (os.graph.version !== 2) {
652
+ throw new Error('migration_required: Graph v1 cannot store canonical ID edges; migrate graph.json to Graph v2 before writing');
653
+ }
578
654
  result = planApply(candidates, { ids: selectedIds, all }, {
579
655
  graphEntities: [...os.graph.entities],
656
+ graphEdges: [...os.graph.edges],
580
657
  relationships: [...os.relationships.relationships],
581
658
  personalKg: os.personalKg,
582
659
  decisions: os.decisions,
@@ -819,12 +896,23 @@ async function writeConfigSnippet(outputPath, payload) {
819
896
  }
820
897
  async function doctor(parsed, io) {
821
898
  const dataDir = resolveDataDir(first(parsed, 'dir'));
822
- const os = await loadPersonalOs(dataDir);
823
- const status = onboardingStatus(os);
899
+ const graphDiagnosis = await diagnoseGraph(dataDir);
900
+ let status;
901
+ try {
902
+ status = onboardingStatus(await loadPersonalOs(dataDir));
903
+ }
904
+ catch (error) {
905
+ write(io, `${JSON.stringify({
906
+ graphDiagnosis,
907
+ localBackend: { connected: false, backend: 'local' },
908
+ issue: error instanceof Error ? error.message : String(error)
909
+ }, null, 2)}\n`);
910
+ return graphDiagnosisExitCode(graphDiagnosis.status) || 1;
911
+ }
824
912
  const judgmentHooksPath = first(parsed, 'judgment-hooks');
825
913
  if (!judgmentHooksPath) {
826
- write(io, `${JSON.stringify(status, null, 2)}\n`);
827
- return 0;
914
+ write(io, `${JSON.stringify({ ...status, graphDiagnosis }, null, 2)}\n`);
915
+ return graphDiagnosisExitCode(graphDiagnosis.status);
828
916
  }
829
917
  const config = JSON.parse(await readFile(judgmentHooksPath, 'utf8'));
830
918
  const hooks = config.hooks;
@@ -841,9 +929,13 @@ async function doctor(parsed, io) {
841
929
  throw new Error('judgment_hooks_invalid');
842
930
  write(io, `${JSON.stringify({
843
931
  ...status,
932
+ graphDiagnosis,
844
933
  judgment_hooks: { status: 'ready', events: requiredEvents, source: judgmentHooksPath }
845
934
  }, null, 2)}\n`);
846
- return 0;
935
+ return graphDiagnosisExitCode(graphDiagnosis.status);
936
+ }
937
+ function graphDiagnosisExitCode(status) {
938
+ return status === 'invalid' || status === 'unavailable' || status === 'migration_required' ? 1 : 0;
847
939
  }
848
940
  function parseArgs(argv) {
849
941
  const [firstToken, ...remaining] = argv;
@@ -905,9 +997,6 @@ function upsertById(entries, entry) {
905
997
  entries.push(entry);
906
998
  }
907
999
  }
908
- function upsertGraphEntity(entities, entity) {
909
- upsertById(entities, entity);
910
- }
911
1000
  function hash(value) {
912
1001
  let hashValue = 0;
913
1002
  for (const char of value) {
@@ -923,7 +1012,10 @@ function writeError(io, text) {
923
1012
  }
924
1013
  async function ontologyAudit(parsed, io) {
925
1014
  const dataDir = resolveDataDir(first(parsed, 'dir'));
926
- const ontologyVersion = resolveOntologyVersion(first(parsed, 'ontology-version'));
1015
+ const requestedVersion = first(parsed, 'ontology-version');
1016
+ const ontologyVersion = requestedVersion === undefined
1017
+ ? undefined
1018
+ : resolveOntologyVersion(requestedVersion);
927
1019
  const result = await auditPersonalOsDirectory(dataDir, { ontologyVersion });
928
1020
  write(io, `${JSON.stringify(result, null, 2)}\n`);
929
1021
  if (result.status === 'unverified') {
@@ -931,6 +1023,15 @@ async function ontologyAudit(parsed, io) {
931
1023
  }
932
1024
  return result.violations.some((violation) => violation.severity === 'error') ? 1 : 0;
933
1025
  }
1026
+ async function ontologyMigrate(parsed, io) {
1027
+ const dataDir = resolveDataDir(first(parsed, 'dir'));
1028
+ const result = await migrateCanonicalGraph(dataDir, {
1029
+ write: parsed.flags.has('write'),
1030
+ expectedInputDigest: first(parsed, 'expected-input-digest')
1031
+ });
1032
+ write(io, `${JSON.stringify(result, null, 2)}\n`);
1033
+ return result.status === 'blocked' ? 1 : 0;
1034
+ }
934
1035
  function proposedPersonalOs(os, proposed) {
935
1036
  return { ...os, ...proposed };
936
1037
  }
@@ -962,7 +1063,8 @@ function usage() {
962
1063
  brainbase onboard:routines --target codex|claude [--routines ohayo,oyasumi,retro] [--ohayo-hour n] [--oyasumi-hour n] [--retro-dow MON-SUN] [--retro-hour n] [--cwd path] [--out path] [--format markdown|json]
963
1064
  brainbase onboard:skills --target codex|claude|portable [--skills id,id] [--out dir] [--format markdown|json]
964
1065
  brainbase ontology:show
965
- brainbase ontology:audit [--dir path] [--ontology-version 0.0.0|1.0.0]
1066
+ brainbase ontology:audit [--dir path] [--ontology-version 0.0.0|1.0.0|2.0.0]
1067
+ brainbase ontology:migrate [--dir path] [--write --expected-input-digest digest]
966
1068
  brainbase judgment:install --target codex [--dry-run] [--output path]
967
1069
  brainbase judgment:hook
968
1070
  brainbase doctor [--dir path] [--judgment-hooks path]