@unson/brainbase-mcp 0.15.1 → 0.15.3
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/dist/canonical-task-service.d.ts +11 -7
- package/dist/canonical-task-service.js +49 -21
- package/dist/cli.js +39 -3
- package/dist/graph-web-http.d.ts +7 -0
- package/dist/graph-web-http.js +14 -0
- package/dist/graph-web.d.ts +36 -6
- package/dist/graph-web.js +45 -4
- package/dist/judgment-value-proof-review.d.ts +24 -0
- package/dist/judgment-value-proof-review.js +120 -9
- package/dist/local-web-host.d.ts +36 -0
- package/dist/local-web-host.js +26 -7
- package/dist/organization-graph-web.d.ts +80 -0
- package/dist/organization-graph-web.js +309 -0
- package/dist/value-proof-review-http.d.ts +5 -0
- package/dist/value-proof-review-http.js +19 -7
- package/dist/world-extension.d.ts +183 -0
- package/dist/world-extension.js +361 -0
- package/package.json +9 -3
- package/ui/graph-projects-view.js +11 -4
- package/ui/graph-registry-view.js +14 -4
- package/ui/graph-view-shared.css +2 -0
- package/ui/graph-view-shared.js +58 -3
- package/ui/local-web-shell.js +74 -12
- package/ui/value-proof-review.js +67 -7
- package/ui/world/world-placement.js +45 -0
- package/ui/world/world-vendor.LICENSE.txt +23 -0
- package/ui/world/world-vendor.js +4220 -0
- package/ui/world/world-view.css +234 -0
- package/ui/world/world-view.js +1092 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { GraphWebError, openInMemoryGraph } from './graph-web.js';
|
|
5
|
+
import { canonicalEdgeId } from './canonical-graph.js';
|
|
6
|
+
import { canonicalGraphOntologyRelease } from './templates.js';
|
|
7
|
+
/**
|
|
8
|
+
* Ledger C1 (adopted 2026-09-24): while the owner validates the Web, the host
|
|
9
|
+
* reads the owner's organization Graph read only. The records are projected
|
|
10
|
+
* into an in-memory Graph v2 so the existing Graph screens read them through
|
|
11
|
+
* the same views as a local Graph. Nothing is written locally or remotely.
|
|
12
|
+
*
|
|
13
|
+
* Relations come only from what the organization Graph states:
|
|
14
|
+
* - `member_of` person → project → `participates_in` (role = role_code)
|
|
15
|
+
* - RACI `assigned_to` raci → person, with the RACI's project:
|
|
16
|
+
* accountable / A / lane decider / decision:* → `accountable_for`
|
|
17
|
+
* any other role → `participates_in`
|
|
18
|
+
* Retired or superseded records are counted, not drawn.
|
|
19
|
+
*/
|
|
20
|
+
export const ORGANIZATION_GRAPH_WEB_VERSION = 'organization-graph-web.v0';
|
|
21
|
+
const ENTITY_TYPES = ['project', 'person', 'org', 'decision'];
|
|
22
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
23
|
+
const CACHE_MS = 60_000;
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
function text(value) {
|
|
28
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
function nameOf(type, payload) {
|
|
31
|
+
return type === 'decision'
|
|
32
|
+
? text(payload.title) ?? text(payload.name) ?? text(payload.decision)?.slice(0, 80) ?? null
|
|
33
|
+
: text(payload.display_name) ?? text(payload.name);
|
|
34
|
+
}
|
|
35
|
+
function isAccountableRole(payload) {
|
|
36
|
+
const role = text(payload.role_code) ?? text(payload.role) ?? '';
|
|
37
|
+
const lane = text(payload.lane) ?? '';
|
|
38
|
+
return role === 'accountable' || role === 'A' || lane === 'decider' || role.startsWith('decision:');
|
|
39
|
+
}
|
|
40
|
+
/** The record that is the project of its scope: its code, or else its id, is the scope's code. */
|
|
41
|
+
function isCatalogProject(record, payload) {
|
|
42
|
+
const scope = text(record.project_code);
|
|
43
|
+
return scope !== null && (text(payload.code) ?? record.id) === scope;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The organization's words for field values: active glossary terms that carry
|
|
47
|
+
* `payload.vocabulary = { field, value }`. The name is the term's label, else
|
|
48
|
+
* its term, else the value itself (pure; tested).
|
|
49
|
+
*/
|
|
50
|
+
export function projectVocabularyTerms(records) {
|
|
51
|
+
const terms = [];
|
|
52
|
+
for (const record of records) {
|
|
53
|
+
if (!isRecord(record) || typeof record.id !== 'string' || !isRecord(record.payload))
|
|
54
|
+
continue;
|
|
55
|
+
if (record.lifecycle_status && record.lifecycle_status !== 'active')
|
|
56
|
+
continue;
|
|
57
|
+
const vocabulary = record.payload.vocabulary;
|
|
58
|
+
const field = isRecord(vocabulary) ? text(vocabulary.field) : null;
|
|
59
|
+
const value = isRecord(vocabulary) ? text(vocabulary.value) : null;
|
|
60
|
+
if (!field || !value)
|
|
61
|
+
continue;
|
|
62
|
+
terms.push({
|
|
63
|
+
id: record.id,
|
|
64
|
+
field,
|
|
65
|
+
value,
|
|
66
|
+
label: text(record.payload.label) ?? text(record.payload.term) ?? value,
|
|
67
|
+
definition: text(record.payload.definition)
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return terms.sort((a, b) => a.id.localeCompare(b.id));
|
|
71
|
+
}
|
|
72
|
+
/** A project's code, parent and repositories as the organization Graph states them. */
|
|
73
|
+
function projectPlacement(record, payload, catalogByCode) {
|
|
74
|
+
const scope = text(record.project_code);
|
|
75
|
+
const isCatalog = isCatalogProject(record, payload);
|
|
76
|
+
const code = text(payload.code) ?? (isCatalog ? scope : null);
|
|
77
|
+
const repositories = Array.isArray(payload.repository_roots)
|
|
78
|
+
? payload.repository_roots.filter(isRecord).map((root) => text(root.repository)).filter((name) => name !== null)
|
|
79
|
+
: [];
|
|
80
|
+
const parentId = scope ? catalogByCode.get(scope) : undefined;
|
|
81
|
+
return {
|
|
82
|
+
...(code ? { code } : {}),
|
|
83
|
+
...(!isCatalog && scope ? { parent_project_code: scope } : {}),
|
|
84
|
+
...(!isCatalog && parentId && parentId !== record.id ? { parent_project_id: parentId } : {}),
|
|
85
|
+
...(repositories.length ? { repositories } : {})
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Projects organization Graph records into a canonical Graph v2 (pure; tested). */
|
|
89
|
+
export function projectOrganizationGraph(records, owner) {
|
|
90
|
+
const entities = [];
|
|
91
|
+
const ids = new Map();
|
|
92
|
+
let inactive = 0;
|
|
93
|
+
let unnamed = 0;
|
|
94
|
+
// The organization's catalog project for each project code (its own code or id equals its scope);
|
|
95
|
+
// other projects in that scope hang under it (the world draws them as its districts).
|
|
96
|
+
const catalogByCode = new Map();
|
|
97
|
+
for (const record of records.entities.project ?? []) {
|
|
98
|
+
if (!isRecord(record) || typeof record.id !== 'string' || (record.lifecycle_status && record.lifecycle_status !== 'active'))
|
|
99
|
+
continue;
|
|
100
|
+
const scope = text(record.project_code);
|
|
101
|
+
if (scope && isCatalogProject(record, isRecord(record.payload) ? record.payload : {}) && !catalogByCode.has(scope))
|
|
102
|
+
catalogByCode.set(scope, record.id);
|
|
103
|
+
}
|
|
104
|
+
for (const type of ENTITY_TYPES) {
|
|
105
|
+
for (const record of records.entities[type] ?? []) {
|
|
106
|
+
if (!isRecord(record) || typeof record.id !== 'string' || ids.has(record.id))
|
|
107
|
+
continue;
|
|
108
|
+
if (record.lifecycle_status && record.lifecycle_status !== 'active') {
|
|
109
|
+
inactive += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const payload = isRecord(record.payload) ? record.payload : {};
|
|
113
|
+
const name = nameOf(type, payload);
|
|
114
|
+
if (!name) {
|
|
115
|
+
unnamed += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const aliases = Array.isArray(payload.aliases) ? payload.aliases.filter((alias) => typeof alias === 'string' && alias.trim() !== '' && alias !== name) : [];
|
|
119
|
+
const summary = text(payload.purpose) ?? text(payload.summary) ?? text(payload.description);
|
|
120
|
+
const status = text(payload.operational_status) ?? text(payload.status);
|
|
121
|
+
entities.push({
|
|
122
|
+
id: record.id,
|
|
123
|
+
type,
|
|
124
|
+
name,
|
|
125
|
+
...(aliases.length ? { aliases: [...new Set(aliases)] } : {}),
|
|
126
|
+
...(summary ? { summary } : {}),
|
|
127
|
+
metadata: {
|
|
128
|
+
source: 'organization_graph',
|
|
129
|
+
...(record.project_code ? { project_code: record.project_code } : {}),
|
|
130
|
+
...(type === 'project' && summary ? { goal: summary } : {}),
|
|
131
|
+
...(type === 'project' && status ? { status } : {}),
|
|
132
|
+
...(type === 'project' && text(payload.kind) ? { kind: text(payload.kind) } : {}),
|
|
133
|
+
...(type === 'project' ? projectPlacement(record, payload, catalogByCode) : {})
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
ids.set(record.id, type);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// One canonical relation per person, relation and project (the edge id is derived from them);
|
|
140
|
+
// the organization's role names for that pair are kept together.
|
|
141
|
+
const byKey = new Map();
|
|
142
|
+
let danglingRelations = 0;
|
|
143
|
+
const addEdge = (sourceId, fromId, relation, toId, role) => {
|
|
144
|
+
if (ids.get(fromId) !== 'person' || ids.get(toId) !== 'project') {
|
|
145
|
+
danglingRelations += 1;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const key = `${fromId}|${relation}|${toId}`;
|
|
149
|
+
const existing = byKey.get(key);
|
|
150
|
+
if (existing) {
|
|
151
|
+
if (role && !existing.roles.includes(role))
|
|
152
|
+
existing.roles.push(role);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
byKey.set(key, {
|
|
156
|
+
edge: { id: canonicalEdgeId({ fromId, relation, toId }), fromId, relation, toId, provenance: { sourceKind: 'import', sourceId } },
|
|
157
|
+
roles: role ? [role] : []
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
for (const edge of records.memberOf) {
|
|
161
|
+
if (edge.lifecycle_status && edge.lifecycle_status !== 'active')
|
|
162
|
+
continue;
|
|
163
|
+
addEdge(edge.id, edge.from_id, 'participates_in', edge.to_id, text(edge.payload?.role_code));
|
|
164
|
+
}
|
|
165
|
+
const raci = new Map((records.entities.raci_assignment ?? []).filter((record) => !record.lifecycle_status || record.lifecycle_status === 'active').map((record) => [record.id, record]));
|
|
166
|
+
for (const edge of records.assignedTo) {
|
|
167
|
+
if (edge.lifecycle_status && edge.lifecycle_status !== 'active')
|
|
168
|
+
continue;
|
|
169
|
+
const assignment = raci.get(edge.from_id);
|
|
170
|
+
const projectId = assignment?.project_id ?? edge.project_id ?? null;
|
|
171
|
+
if (!assignment || !projectId) {
|
|
172
|
+
danglingRelations += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const payload = isRecord(assignment.payload) ? assignment.payload : {};
|
|
176
|
+
const role = text(payload.role_code) ?? text(payload.role) ?? text(edge.payload?.role_code);
|
|
177
|
+
addEdge(edge.id, edge.to_id, isAccountableRole(payload) ? 'accountable_for' : 'participates_in', projectId, role);
|
|
178
|
+
}
|
|
179
|
+
const edges = [...byKey.values()].map(({ edge, roles }) => (roles.length ? { ...edge, role: roles.join('、') } : edge));
|
|
180
|
+
const ownerEntity = owner && ids.get(owner.id) === 'person' ? owner : undefined;
|
|
181
|
+
return {
|
|
182
|
+
graph: {
|
|
183
|
+
version: 2,
|
|
184
|
+
ontology: { ...canonicalGraphOntologyRelease.binding },
|
|
185
|
+
owner: ownerEntity ? { id: ownerEntity.id, ...(ownerEntity.name ? { name: ownerEntity.name } : {}) } : {},
|
|
186
|
+
entities,
|
|
187
|
+
edges
|
|
188
|
+
},
|
|
189
|
+
excluded: { inactive, unnamed, danglingRelations }
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** The owner's Brainbase server and token, read at request time (tokens rotate hourly). Never logged or returned. */
|
|
193
|
+
export async function readOrganizationAccess(home = homedir()) {
|
|
194
|
+
try {
|
|
195
|
+
const config = JSON.parse(await readFile(join(home, '.brainbase', 'config.json'), 'utf8'));
|
|
196
|
+
const tokens = JSON.parse(await readFile(join(home, '.brainbase', 'tokens.json'), 'utf8'));
|
|
197
|
+
const server = isRecord(config) ? text(config.server_url) : null;
|
|
198
|
+
const token = isRecord(tokens) ? text(tokens.access_token) : null;
|
|
199
|
+
if (!server || !/^https:\/\/[^/?#@]+$/u.test(server))
|
|
200
|
+
return { reason: 'server_url_missing_or_invalid' };
|
|
201
|
+
if (!token)
|
|
202
|
+
return { reason: 'access_token_missing' };
|
|
203
|
+
return { server, token };
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return { reason: 'brainbase_auth_files_unreadable' };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function getRecords(request, access, path) {
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
212
|
+
try {
|
|
213
|
+
const response = await request(`${access.server}/api/info/graph/${path}`, {
|
|
214
|
+
method: 'GET',
|
|
215
|
+
headers: { Authorization: `Bearer ${access.token}`, Accept: 'application/json' },
|
|
216
|
+
redirect: 'error',
|
|
217
|
+
signal: controller.signal
|
|
218
|
+
});
|
|
219
|
+
if (response.status === 401 || response.status === 403) {
|
|
220
|
+
throw new GraphWebError('unavailable', 'organization_graph_auth_failed', `The organization Graph refused the owner's token (HTTP ${response.status}); sign in again`);
|
|
221
|
+
}
|
|
222
|
+
if (!response.ok)
|
|
223
|
+
throw new GraphWebError('unavailable', 'organization_graph_unavailable', `The organization Graph answered HTTP ${response.status} for ${path.split('?')[0]}`);
|
|
224
|
+
const body = await response.json();
|
|
225
|
+
if (!isRecord(body) || !Array.isArray(body.records))
|
|
226
|
+
throw new GraphWebError('unavailable', 'organization_graph_unavailable', 'The organization Graph response has no records');
|
|
227
|
+
return body.records;
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (error instanceof GraphWebError)
|
|
231
|
+
throw error;
|
|
232
|
+
const reason = error instanceof Error && error.name === 'AbortError' ? 'timed out' : 'request failed';
|
|
233
|
+
throw new GraphWebError('unavailable', 'organization_graph_unavailable', `The organization Graph ${reason}`);
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The organization Graph for the local host (C1). Reads are cached briefly and
|
|
241
|
+
* shared while in flight; a failure is reported as `unavailable`, never as an
|
|
242
|
+
* empty Graph.
|
|
243
|
+
*/
|
|
244
|
+
export async function createOrganizationGraphSource(options = {}) {
|
|
245
|
+
const home = options.home ?? homedir();
|
|
246
|
+
const initial = await readOrganizationAccess(home);
|
|
247
|
+
if ('reason' in initial)
|
|
248
|
+
return initial;
|
|
249
|
+
const request = options.fetch ?? fetch;
|
|
250
|
+
const now = options.now ?? (() => new Date());
|
|
251
|
+
const cacheMs = options.cacheMs ?? CACHE_MS;
|
|
252
|
+
let cached = null;
|
|
253
|
+
let inFlight = null;
|
|
254
|
+
async function load() {
|
|
255
|
+
const access = await readOrganizationAccess(home);
|
|
256
|
+
if ('reason' in access)
|
|
257
|
+
throw new GraphWebError('unavailable', 'organization_graph_not_connected', `The organization Graph cannot be read (${access.reason})`);
|
|
258
|
+
// The organization's words are read alongside; failing to read them never fails the Graph.
|
|
259
|
+
const termsRead = getRecords(request, access, 'entities?type=glossary_term&limit=500')
|
|
260
|
+
.then((records) => projectVocabularyTerms(records))
|
|
261
|
+
.catch(() => null);
|
|
262
|
+
const [project, person, org, decision, raci, memberOf, assignedTo] = await Promise.all([
|
|
263
|
+
getRecords(request, access, 'entities?type=project&limit=500'),
|
|
264
|
+
getRecords(request, access, 'entities?type=person&limit=500'),
|
|
265
|
+
getRecords(request, access, 'entities?type=org&limit=500'),
|
|
266
|
+
getRecords(request, access, 'entities?type=decision&limit=500'),
|
|
267
|
+
getRecords(request, access, 'entities?type=raci_assignment&limit=500'),
|
|
268
|
+
getRecords(request, access, 'edges?type=member_of'),
|
|
269
|
+
getRecords(request, access, 'edges?type=assigned_to')
|
|
270
|
+
]);
|
|
271
|
+
const projection = projectOrganizationGraph({
|
|
272
|
+
entities: {
|
|
273
|
+
project: project,
|
|
274
|
+
person: person,
|
|
275
|
+
org: org,
|
|
276
|
+
decision: decision,
|
|
277
|
+
raci_assignment: raci
|
|
278
|
+
},
|
|
279
|
+
memberOf: memberOf,
|
|
280
|
+
assignedTo: assignedTo
|
|
281
|
+
});
|
|
282
|
+
const source = { dataDir: null, graphFormat: 2, authority: 'organization_graph', server: access.server, readAt: now().toISOString() };
|
|
283
|
+
return { reader: openInMemoryGraph(projection.graph, source), graph: projection.graph, terms: await termsRead };
|
|
284
|
+
}
|
|
285
|
+
async function current() {
|
|
286
|
+
const at = now().getTime();
|
|
287
|
+
if (cached && at - cached.at < cacheMs)
|
|
288
|
+
return cached.loaded;
|
|
289
|
+
inFlight ??= load().then((loaded) => {
|
|
290
|
+
cached = { at: now().getTime(), loaded };
|
|
291
|
+
return loaded;
|
|
292
|
+
}).finally(() => {
|
|
293
|
+
inFlight = null;
|
|
294
|
+
});
|
|
295
|
+
return inFlight;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
server: initial.server,
|
|
299
|
+
async read() {
|
|
300
|
+
return (await current()).reader;
|
|
301
|
+
},
|
|
302
|
+
async readGraphFile() {
|
|
303
|
+
return (await current()).graph;
|
|
304
|
+
},
|
|
305
|
+
async readVocabularyTerms() {
|
|
306
|
+
return (await current()).terms;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
2
|
+
import { JudgmentValueProofJournalCache, type JudgmentValueProofReviewHome } from './judgment-value-proof-review.js';
|
|
2
3
|
export declare const VALUE_PROOF_REVIEW_HTTP_VERSION: "value-proof-review-http.v1";
|
|
3
4
|
export declare const VALUE_PROOF_REVIEW_TOKEN_HEADER = "x-brainbase-review-token";
|
|
4
5
|
export interface ValueProofReviewHttpOptions {
|
|
@@ -10,8 +11,12 @@ export interface ValueProofReviewHttpOptions {
|
|
|
10
11
|
readonly token: string;
|
|
11
12
|
readonly basePath?: string;
|
|
12
13
|
readonly now?: () => Date;
|
|
14
|
+
/** One cache per journal; a host passes the one it shares with other readers of the same journal. */
|
|
15
|
+
readonly journalCache?: JudgmentValueProofJournalCache;
|
|
13
16
|
}
|
|
14
17
|
export type ValueProofReviewHttpHandler = (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
|
|
18
|
+
/** The home as the browser receives it: no local paths beyond the journal root. */
|
|
19
|
+
export declare function publicHome(home: JudgmentValueProofReviewHome): unknown;
|
|
15
20
|
export declare function createValueProofReviewHttpHandler(options: ValueProofReviewHttpOptions): ValueProofReviewHttpHandler;
|
|
16
21
|
export interface ValueProofReviewHostOptions extends Omit<ValueProofReviewHttpOptions, 'token'> {
|
|
17
22
|
readonly token?: string;
|
|
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { applyJudgmentValueProofFeedback, buildJudgmentValueProofReviewHome, classifyJudgmentValueProof, JudgmentValueProofAnswerConflictError, readJudgmentValueProofAnswers, readJudgmentValueProofFeedback, readJudgmentValueProofJournal, recordJudgmentValueProofAnswer, recordJudgmentValueProofFeedback } from './judgment-value-proof-review.js';
|
|
6
|
+
import { applyJudgmentValueProofFeedback, buildJudgmentValueProofReviewHome, classifyJudgmentValueProof, JudgmentValueProofAnswerConflictError, JudgmentValueProofJournalCache, readJudgmentValueProofAnswers, readJudgmentValueProofFeedback, readJudgmentValueProofJournal, recordJudgmentValueProofAnswer, recordJudgmentValueProofFeedback } from './judgment-value-proof-review.js';
|
|
7
7
|
import { assertSameOrigin, isLoopbackHost, LOCAL_WEB_CONTENT_SECURITY_POLICY, LocalWebHttpError as HttpError, readJsonObjectBody, requestUrl, tokenMatches, writeHttpError as writeError, writeJson } from './local-web-security.js';
|
|
8
8
|
export const VALUE_PROOF_REVIEW_HTTP_VERSION = 'value-proof-review-http.v1';
|
|
9
9
|
export const VALUE_PROOF_REVIEW_TOKEN_HEADER = 'x-brainbase-review-token';
|
|
@@ -17,13 +17,18 @@ const UI_FILES = Object.freeze({
|
|
|
17
17
|
'/ui/value-proof-review.js': { file: 'value-proof-review.js', type: 'text/javascript; charset=utf-8' },
|
|
18
18
|
'/ui/value-proof-review.css': { file: 'value-proof-review.css', type: 'text/css; charset=utf-8' }
|
|
19
19
|
});
|
|
20
|
-
|
|
20
|
+
/** The home as the browser receives it: no local paths beyond the journal root. */
|
|
21
|
+
export function publicHome(home) {
|
|
21
22
|
if (home.status === 'unavailable')
|
|
22
23
|
return home;
|
|
23
24
|
const strip = (items) => Object.fromEntries(Object.entries(items).map(([section, list]) => [
|
|
24
25
|
section,
|
|
25
|
-
list.map(({ section: itemSection, proof, feedback_history, answer }) => ({
|
|
26
|
-
section: itemSection,
|
|
26
|
+
list.map(({ section: itemSection, proof, feedback_history, answer, conversation_moved_on_at }) => ({
|
|
27
|
+
section: itemSection,
|
|
28
|
+
proof,
|
|
29
|
+
feedback_history,
|
|
30
|
+
answer: answer ?? null,
|
|
31
|
+
conversation_moved_on_at: conversation_moved_on_at ?? null
|
|
27
32
|
}))
|
|
28
33
|
]));
|
|
29
34
|
return {
|
|
@@ -44,8 +49,11 @@ export function createValueProofReviewHttpHandler(options) {
|
|
|
44
49
|
}
|
|
45
50
|
const basePath = (options.basePath ?? DEFAULT_BASE_PATH).replace(/\/+$/u, '');
|
|
46
51
|
const now = options.now ?? (() => new Date());
|
|
52
|
+
// A large journal has thousands of conversation folders; only the ones that changed are listed again.
|
|
53
|
+
const journalCache = options.journalCache ?? new JudgmentValueProofJournalCache();
|
|
54
|
+
const readJournal = () => readJudgmentValueProofJournal({ root: options.journalRoot, cache: journalCache });
|
|
47
55
|
async function readHome() {
|
|
48
|
-
const journal = await
|
|
56
|
+
const journal = await readJournal();
|
|
49
57
|
if (journal.status !== 'available')
|
|
50
58
|
return buildJudgmentValueProofReviewHome(journal, [], { now: now() });
|
|
51
59
|
const [feedback, answers] = await Promise.all([
|
|
@@ -65,7 +73,7 @@ export function createValueProofReviewHttpHandler(options) {
|
|
|
65
73
|
const body = await authorizedBody(request);
|
|
66
74
|
const intentId = typeof body.intent_id === 'string' ? body.intent_id : '';
|
|
67
75
|
const attemptId = typeof body.decision_attempt_id === 'string' ? body.decision_attempt_id : '';
|
|
68
|
-
const journal = await
|
|
76
|
+
const journal = await readJournal();
|
|
69
77
|
if (journal.status === 'unavailable') {
|
|
70
78
|
throw new HttpError(503, 'judgment_journal_unavailable', journal.reason);
|
|
71
79
|
}
|
|
@@ -78,6 +86,10 @@ export function createValueProofReviewHttpHandler(options) {
|
|
|
78
86
|
if (classifyJudgmentValueProof(proof) !== 'needs_human' || !proof.human_decision) {
|
|
79
87
|
throw new HttpError(422, 'not_answerable', 'This judgment is not waiting for the owner');
|
|
80
88
|
}
|
|
89
|
+
if (entry.conversation_moved_on_at) {
|
|
90
|
+
// The owner answered in that conversation or moved on; answering here could replay a settled question.
|
|
91
|
+
throw new HttpError(422, 'conversation_moved_on', 'The conversation moved on after this question; continue it there');
|
|
92
|
+
}
|
|
81
93
|
try {
|
|
82
94
|
const result = await recordJudgmentValueProofAnswer({
|
|
83
95
|
dataDir: options.dataDir,
|
|
@@ -108,7 +120,7 @@ export function createValueProofReviewHttpHandler(options) {
|
|
|
108
120
|
const body = await authorizedBody(request);
|
|
109
121
|
const intentId = typeof body.intent_id === 'string' ? body.intent_id : '';
|
|
110
122
|
const attemptId = typeof body.decision_attempt_id === 'string' ? body.decision_attempt_id : '';
|
|
111
|
-
const journal = await
|
|
123
|
+
const journal = await readJournal();
|
|
112
124
|
if (journal.status === 'unavailable') {
|
|
113
125
|
throw new HttpError(503, 'judgment_journal_unavailable', journal.reason);
|
|
114
126
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { JudgmentValueProofJournalCache } from './judgment-value-proof-review.js';
|
|
2
|
+
import { type GraphVocabularyTerm, type LocalWebExtension, type LocalWebModuleContext } from './local-web-host.js';
|
|
3
|
+
import type { GraphFileV2 } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* World view (ledger P17, adopted 2026-10-04): the home's upper layer.
|
|
6
|
+
*
|
|
7
|
+
* The world is a read-only projection. Businesses come from the same Graph
|
|
8
|
+
* the other screens read (the organization Graph under C1, else the local
|
|
9
|
+
* Graph); judgments come from the existing value-proof home on the browser
|
|
10
|
+
* side. Nothing here writes, and a failure to read is reported as a state,
|
|
11
|
+
* never as zero businesses.
|
|
12
|
+
*/
|
|
13
|
+
export declare const WORLD_EXTENSION_ID = "world";
|
|
14
|
+
export declare const WORLD_EXTENSION_VERSION: "world-extension.v1";
|
|
15
|
+
declare const LIFECYCLE_PHASES: readonly ["active", "maintenance", "finished", "concept"];
|
|
16
|
+
/** How a status looks in the world: lit, muted, an empty lot, or a scaffold. */
|
|
17
|
+
export type WorldLifecyclePhase = (typeof LIFECYCLE_PHASES)[number];
|
|
18
|
+
declare const BUILDING_FORMS: readonly ["tower", "hall", "dome", "office"];
|
|
19
|
+
export type WorldBuildingForm = (typeof BUILDING_FORMS)[number];
|
|
20
|
+
/**
|
|
21
|
+
* The words of the owner's Graph, given by the host (`--world-vocabulary`):
|
|
22
|
+
* the project kinds in display order with their label and building, and the
|
|
23
|
+
* statuses with their label and lifecycle phase. None of it is required: a
|
|
24
|
+
* kind the vocabulary does not name is drawn under its own value, and a status
|
|
25
|
+
* it does not name keeps its value and the active look.
|
|
26
|
+
*/
|
|
27
|
+
export interface WorldVocabularyConfig {
|
|
28
|
+
readonly kinds?: Readonly<Record<string, {
|
|
29
|
+
readonly label?: string;
|
|
30
|
+
readonly form?: WorldBuildingForm;
|
|
31
|
+
readonly color?: string;
|
|
32
|
+
}>>;
|
|
33
|
+
readonly statuses?: Readonly<Record<string, {
|
|
34
|
+
readonly label?: string;
|
|
35
|
+
readonly phase?: WorldLifecyclePhase;
|
|
36
|
+
}>>;
|
|
37
|
+
}
|
|
38
|
+
export interface WorldVocabulary {
|
|
39
|
+
readonly kinds: readonly {
|
|
40
|
+
readonly key: string | null;
|
|
41
|
+
readonly label: string;
|
|
42
|
+
/** Where the label came from: the organization's term, the host's vocabulary file, or the value itself. */
|
|
43
|
+
readonly label_source: 'graph' | 'config' | 'value';
|
|
44
|
+
readonly definition: string | null;
|
|
45
|
+
readonly form: WorldBuildingForm;
|
|
46
|
+
readonly color: string;
|
|
47
|
+
readonly configured: boolean;
|
|
48
|
+
}[];
|
|
49
|
+
/** Whether the organization's terms were read: `unavailable` when they could not be, `none` without an organization Graph. */
|
|
50
|
+
readonly terms: 'read' | 'unavailable' | 'none';
|
|
51
|
+
readonly statuses: readonly {
|
|
52
|
+
readonly key: string;
|
|
53
|
+
readonly label: string;
|
|
54
|
+
readonly phase: WorldLifecyclePhase;
|
|
55
|
+
}[];
|
|
56
|
+
}
|
|
57
|
+
export interface WorldEngagement {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
/** The project's own code in its Graph (`metadata.code`), or null when it has none. */
|
|
60
|
+
readonly code: string | null;
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly summary: string | null;
|
|
63
|
+
readonly status: string | null;
|
|
64
|
+
}
|
|
65
|
+
export interface WorldBusiness {
|
|
66
|
+
readonly id: string;
|
|
67
|
+
/** The project's code in its Graph (`metadata.code`), else its id; judgments are placed by it. */
|
|
68
|
+
readonly code: string;
|
|
69
|
+
readonly name: string;
|
|
70
|
+
/** `metadata.kind`, or null when the Graph does not classify the project. */
|
|
71
|
+
readonly kind: string | null;
|
|
72
|
+
readonly purpose: string | null;
|
|
73
|
+
readonly status: string | null;
|
|
74
|
+
/** Repository names the Graph registers for this project (`metadata.repositories`). */
|
|
75
|
+
readonly repositories: readonly string[];
|
|
76
|
+
readonly engagements: readonly WorldEngagement[];
|
|
77
|
+
}
|
|
78
|
+
export interface WorldBusinessProjection {
|
|
79
|
+
readonly businesses: readonly WorldBusiness[];
|
|
80
|
+
/** Projects that name a parent the Graph does not hold (kept visible as a count, never dropped silently). */
|
|
81
|
+
readonly unplaced: readonly WorldEngagement[];
|
|
82
|
+
readonly excluded: {
|
|
83
|
+
readonly inactive: number;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export type WorldSource = {
|
|
87
|
+
readonly authority: 'organization_graph';
|
|
88
|
+
readonly server: string;
|
|
89
|
+
} | {
|
|
90
|
+
readonly authority: 'local';
|
|
91
|
+
readonly dataDir: string;
|
|
92
|
+
};
|
|
93
|
+
export type WorldBusinessesResponse = ({
|
|
94
|
+
readonly status: 'ok';
|
|
95
|
+
readonly version: typeof WORLD_EXTENSION_VERSION;
|
|
96
|
+
readonly source: WorldSource;
|
|
97
|
+
readonly as_of: string;
|
|
98
|
+
readonly vocabulary: WorldVocabulary;
|
|
99
|
+
} & WorldBusinessProjection) | {
|
|
100
|
+
readonly status: 'not_initialized' | 'migration_required' | 'unavailable';
|
|
101
|
+
readonly version: typeof WORLD_EXTENSION_VERSION;
|
|
102
|
+
readonly reason: string;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The projects of a Graph v2 → cities (top-level projects) and their districts
|
|
106
|
+
* (projects whose `metadata.parent_project_id` names another project). A
|
|
107
|
+
* project whose declared parent (`metadata.parent_project_code`) is not in the
|
|
108
|
+
* Graph is counted as unplaced. Projects past `validTo` are counted, not drawn.
|
|
109
|
+
* Nothing here knows any organization's words: kinds and statuses are the
|
|
110
|
+
* Graph's own values.
|
|
111
|
+
*/
|
|
112
|
+
export declare function projectWorldFromGraph(graph: Pick<GraphFileV2, 'entities'>, now?: Date): WorldBusinessProjection;
|
|
113
|
+
/**
|
|
114
|
+
* The vocabulary the world draws with: the host's kinds in their order, then
|
|
115
|
+
* kinds the Graph uses that the host did not name (under their own value),
|
|
116
|
+
* then unclassified projects; statuses are the common lifecycle words with the
|
|
117
|
+
* host's on top.
|
|
118
|
+
*/
|
|
119
|
+
export declare function resolveWorldVocabulary(config: WorldVocabularyConfig | undefined, businesses: readonly WorldBusiness[], terms?: readonly GraphVocabularyTerm[] | null | undefined): WorldVocabulary;
|
|
120
|
+
/** Reads and checks a vocabulary file; an invalid file is an error, never silently ignored. */
|
|
121
|
+
export declare function readWorldVocabulary(path: string): Promise<WorldVocabularyConfig>;
|
|
122
|
+
/**
|
|
123
|
+
* The businesses from the same Graph the other screens read: the organization
|
|
124
|
+
* Graph when the host reads it (C1), else the local Graph. A Graph that cannot
|
|
125
|
+
* be read is a state with its reason, never zero businesses.
|
|
126
|
+
*/
|
|
127
|
+
export declare function readWorldBusinesses(context: Pick<LocalWebModuleContext, 'dataDir' | 'organizationGraph'>, options?: {
|
|
128
|
+
readonly vocabulary?: WorldVocabularyConfig;
|
|
129
|
+
readonly now?: () => Date;
|
|
130
|
+
}): Promise<WorldBusinessesResponse>;
|
|
131
|
+
/**
|
|
132
|
+
* The world of an organization from the project and glossary-term records its Graph API returns, for an
|
|
133
|
+
* organization web that hosts the same world screen (P16). Pure: the caller reads the records with the
|
|
134
|
+
* member's own grants, so the world shows only what that member may read.
|
|
135
|
+
*/
|
|
136
|
+
export declare function projectOrganizationWorld(records: {
|
|
137
|
+
readonly projects: readonly unknown[];
|
|
138
|
+
readonly glossaryTerms?: readonly unknown[];
|
|
139
|
+
}, options: {
|
|
140
|
+
readonly server: string;
|
|
141
|
+
readonly vocabulary?: WorldVocabularyConfig;
|
|
142
|
+
readonly now?: Date;
|
|
143
|
+
}): WorldBusinessesResponse;
|
|
144
|
+
export interface WorldJudgmentPlace {
|
|
145
|
+
readonly decision_attempt_id: string;
|
|
146
|
+
/** Repository the judgment Host recorded for the turn (`turn-input.json` project_code); null when unrecorded. */
|
|
147
|
+
readonly workspace: string | null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Reads, for every saved value proof in the judgment journal, the repository
|
|
151
|
+
* the Host recorded for that turn. The journal is listed through the host's
|
|
152
|
+
* shared listing cache (the same one as the 今日 list), so only folders that
|
|
153
|
+
* changed are listed again; turn inputs are never rewritten by the Host, so
|
|
154
|
+
* each is read once. A journal that cannot be read is `unavailable`, never
|
|
155
|
+
* an empty list.
|
|
156
|
+
*/
|
|
157
|
+
export declare function readJudgmentPlaces(journalRoot: string, options?: {
|
|
158
|
+
readonly cache?: JudgmentValueProofJournalCache;
|
|
159
|
+
readonly workspaces?: Map<string, string | null>;
|
|
160
|
+
}): Promise<{
|
|
161
|
+
readonly status: 'available' | 'unavailable';
|
|
162
|
+
readonly places: readonly WorldJudgmentPlace[];
|
|
163
|
+
readonly reason?: string;
|
|
164
|
+
}>;
|
|
165
|
+
/**
|
|
166
|
+
* The judgments of one organization, for a world drawn from that organization's Graph. A judgment
|
|
167
|
+
* belongs to the organization only when the repository it was worked in is one of the organization's
|
|
168
|
+
* businesses (a registered repository or the business code, the same rule as the world's placement).
|
|
169
|
+
* Every other judgment (another company's work, or one whose repository was not recorded) is withheld:
|
|
170
|
+
* only their count leaves the host, never their questions or repositories.
|
|
171
|
+
*/
|
|
172
|
+
export declare function readOrganizationJudgments(context: Pick<LocalWebModuleContext, 'journalRoot' | 'journalCache' | 'dataDir' | 'now'>, businesses: readonly Pick<WorldBusiness, 'code' | 'repositories'>[], workspaces?: Map<string, string | null>): Promise<{
|
|
173
|
+
readonly status: 'available' | 'unavailable';
|
|
174
|
+
readonly scope: 'organization';
|
|
175
|
+
readonly withheld: number;
|
|
176
|
+
readonly places: readonly WorldJudgmentPlace[];
|
|
177
|
+
readonly home: unknown;
|
|
178
|
+
readonly reason?: string;
|
|
179
|
+
}>;
|
|
180
|
+
export declare function createWorldExtension(options?: {
|
|
181
|
+
readonly vocabulary?: WorldVocabularyConfig;
|
|
182
|
+
}): LocalWebExtension;
|
|
183
|
+
export {};
|