@unson/brainbase-mcp 0.23.2 → 0.23.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.
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Read-only judgment history for the local and organization workspaces.
2
+ * Judgment history projection for the local and organization workspaces.
3
3
  *
4
4
  * The host already owns the journal read model. This module deliberately
5
5
  * projects that model in the browser instead of creating another ledger. It
@@ -28,6 +28,20 @@ const JST_OFFSET_MS = 9 * 60 * 60 * 1000;
28
28
  const PERIODS = Object.freeze({ week: 'week', past30days: 'past30days', all: 'all' });
29
29
  const PERIOD_LABELS = Object.freeze({ week: '今週', past30days: '過去30日', all: '全期間' });
30
30
  const SECTION_KEYS = Object.freeze(['needs_human', 'blocked', 'continued', 'other']);
31
+ const NORMAL_ENTRYPOINTS = Object.freeze(['codex', 'claude_code', 'mana', 'company_os', 'unknown']);
32
+ const NORMAL_ENTRYPOINT_LABELS = Object.freeze({
33
+ codex: 'Codex',
34
+ claude_code: 'Claude Code',
35
+ mana: 'Mana',
36
+ company_os: 'Company OS',
37
+ unknown: '入口不明',
38
+ });
39
+ const FEEDBACK_KINDS = Object.freeze(['feedback', 'result', 'correction']);
40
+ const FEEDBACK_KIND_LABELS = Object.freeze({
41
+ feedback: 'フィードバック',
42
+ result: '結果',
43
+ correction: '訂正',
44
+ });
31
45
 
32
46
  function isRecord(value) {
33
47
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -37,6 +51,63 @@ function text(value) {
37
51
  return typeof value === 'string' ? value.trim() : '';
38
52
  }
39
53
 
54
+ function canonicalJson(value) {
55
+ if (value === null || typeof value !== 'object') {
56
+ if (typeof value === 'number' && !Number.isFinite(value)) return JSON.stringify(null);
57
+ return JSON.stringify(value);
58
+ }
59
+ if (Array.isArray(value)) return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
60
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
61
+ }
62
+
63
+ function feedbackKind(value) {
64
+ const candidate = text(value).toLowerCase();
65
+ return FEEDBACK_KINDS.includes(candidate) ? candidate : null;
66
+ }
67
+
68
+ function normalizeFeedbackEvent(value, fallbackRecordId) {
69
+ if (!isRecord(value)) return null;
70
+ const eventId = text(value.event_id ?? value.eventId ?? value.id);
71
+ const recordId = text(value.record_id ?? value.recordId ?? fallbackRecordId);
72
+ const kind = feedbackKind(value.kind ?? value.type);
73
+ if (!eventId || !recordId || !kind || !isRecord(value.content)) return null;
74
+ return {
75
+ recordId,
76
+ eventId,
77
+ kind,
78
+ content: value.content,
79
+ recordedAt: text(value.recorded_at ?? value.recordedAt) || null,
80
+ saved: value.saved === true,
81
+ storageStatus: text(value.storage_status ?? value.storageStatus ?? value.save_status).toLowerCase() || null,
82
+ };
83
+ }
84
+
85
+ function normalizeFeedbackEvents(value, fallbackRecordId) {
86
+ if (!isRecord(value)) return [];
87
+ const candidates = [
88
+ value.feedback_events,
89
+ value.feedback,
90
+ isRecord(value.feedback) ? value.feedback.events : undefined,
91
+ value.events,
92
+ value.appended_events,
93
+ value.event,
94
+ value.feedback_event,
95
+ ];
96
+ const collection = candidates.find((candidate) => Array.isArray(candidate))
97
+ ?? candidates.find((candidate) => isRecord(candidate));
98
+ if (!collection) return [];
99
+ const entries = Array.isArray(collection) ? collection : [collection];
100
+ const seen = new Set();
101
+ const result = [];
102
+ for (const candidate of entries) {
103
+ const event = normalizeFeedbackEvent(candidate, fallbackRecordId);
104
+ if (!event || seen.has(event.eventId)) continue;
105
+ seen.add(event.eventId);
106
+ result.push(event);
107
+ }
108
+ return result;
109
+ }
110
+
40
111
  function findFirst(node, predicate) {
41
112
  if (!node) return null;
42
113
  if (predicate(node)) return node;
@@ -87,9 +158,21 @@ function normalizeCoverage(coverage, status) {
87
158
  const complete = source.complete !== false && state !== 'partial' && state !== 'unknown';
88
159
  const saved = nonNegativeInteger(source.saved ?? source.read ?? source.count);
89
160
  const rejected = nonNegativeInteger(source.rejected ?? source.rejected_count);
161
+ const total = nonNegativeInteger(source.total);
162
+ const storage = text(source.storage).toLowerCase() || null;
163
+ const sources = Array.isArray(source.sources)
164
+ ? source.sources.filter(isRecord).map((entry) => ({
165
+ entrypoint: text(entry.entrypoint) || 'unknown',
166
+ status: text(entry.status) || 'unknown',
167
+ reason: text(entry.reason) || null,
168
+ }))
169
+ : [];
90
170
  return {
91
171
  saved,
92
172
  rejected,
173
+ total,
174
+ storage,
175
+ sources,
93
176
  latestRecordedAt: text(source.latest_recorded_at ?? source.latestRecordedAt) || null,
94
177
  possiblyStalled: source.possibly_stalled === true || source.possiblyStalled === true,
95
178
  complete: status !== 'partial' && complete,
@@ -98,6 +181,124 @@ function normalizeCoverage(coverage, status) {
98
181
  };
99
182
  }
100
183
 
184
+ function normalEntrypoint(value) {
185
+ const candidate = text(value).toLowerCase();
186
+ return NORMAL_ENTRYPOINTS.includes(candidate) ? candidate : 'unknown';
187
+ }
188
+
189
+ function normalizeNormalReference(value) {
190
+ if (!isRecord(value)) return null;
191
+ const ref = text(value.ref ?? value.entity_id ?? value.id);
192
+ if (!ref) return null;
193
+ return {
194
+ ref,
195
+ id: ref,
196
+ kind: text(value.kind) || null,
197
+ version: text(value.version ?? value.revision) || null,
198
+ digest: text(value.digest) || null,
199
+ why: text(value.why) || null,
200
+ usage: text(value.usage) || null,
201
+ availability: text(value.availability).toLowerCase() || 'unknown',
202
+ label: text(value.label ?? value.name) || ref,
203
+ };
204
+ }
205
+
206
+ function normalizeNormalAlternative(value) {
207
+ if (!isRecord(value)) return null;
208
+ const adopted = typeof value.adopted === 'boolean' ? value.adopted : null;
209
+ return {
210
+ label: text(value.label ?? value.summary ?? value.option) || null,
211
+ evaluation: text(value.evaluation ?? value.impact ?? value.reason) || null,
212
+ adoption: text(value.adoption ?? value.adoption_status ?? value.selected) || null,
213
+ adopted,
214
+ };
215
+ }
216
+
217
+ function normalizeNormalRecord(value) {
218
+ if (!isRecord(value)) return null;
219
+ const recordId = text(value.record_id);
220
+ const recordedAt = text(value.recorded_at);
221
+ if (!recordId || timestamp(recordedAt) === null) return null;
222
+ const judgment = isRecord(value.judgment) ? value.judgment : {};
223
+ const execution = isRecord(value.execution) ? value.execution : {};
224
+ const invalidFields = [];
225
+ const normalizeCollection = (value, field, normalize) => {
226
+ if (value === null || value === undefined) return null;
227
+ if (!Array.isArray(value)) {
228
+ invalidFields.push(field);
229
+ return null;
230
+ }
231
+ const normalized = value.map(normalize);
232
+ if (normalized.some((entry) => entry === null)) invalidFields.push(field);
233
+ return normalized.filter(Boolean);
234
+ };
235
+ const selectedReferences = normalizeCollection(judgment.selected_references, 'judgment.selected_references', normalizeNormalReference);
236
+ const alternatives = normalizeCollection(judgment.alternatives, 'judgment.alternatives', normalizeNormalAlternative);
237
+ const missingFields = Array.isArray(value.missing_fields)
238
+ ? value.missing_fields.map((field) => text(field)).filter(Boolean)
239
+ : [];
240
+ for (const field of invalidFields) {
241
+ if (!missingFields.includes(field)) missingFields.push(field);
242
+ }
243
+ return {
244
+ record: value,
245
+ recordId,
246
+ entrypoint: normalEntrypoint(value.entrypoint),
247
+ recordedAt,
248
+ projectCode: value.project_code === null || value.project_code === undefined ? null : text(value.project_code) || null,
249
+ turnRef: value.turn_ref === null || value.turn_ref === undefined ? null : text(value.turn_ref) || null,
250
+ judgment,
251
+ execution,
252
+ selectedReferences,
253
+ alternatives,
254
+ feedbackEvents: normalizeFeedbackEvents(value, recordId),
255
+ missingFields,
256
+ invalidFields,
257
+ };
258
+ }
259
+
260
+ function normalizeNormalFilters(filters) {
261
+ if (!isRecord(filters)) return { period: null, project: null, entrypoint: null, projects: [], entrypoints: [] };
262
+ const normalizeOptions = (value) => {
263
+ if (!Array.isArray(value)) return [];
264
+ return value.map((entry) => {
265
+ if (typeof entry === 'string') return { value: entry, label: entry };
266
+ if (!isRecord(entry)) return null;
267
+ const option = text(entry.value ?? entry.code ?? entry.project ?? entry.entrypoint ?? entry.id);
268
+ return option ? { value: option, label: text(entry.label) || option } : null;
269
+ }).filter(Boolean);
270
+ };
271
+ const projects = normalizeOptions(filters.projects ?? filters.project_options ?? filters.projectCodes);
272
+ const entrypoints = normalizeOptions(filters.entrypoints ?? filters.entrypoint_options)
273
+ .filter((entry) => NORMAL_ENTRYPOINTS.includes(entry.value));
274
+ return {
275
+ period: text(filters.period) || null,
276
+ project: filters.project === null || filters.project === undefined ? null : text(filters.project) || null,
277
+ entrypoint: filters.entrypoint === null || filters.entrypoint === undefined ? null : normalEntrypoint(filters.entrypoint),
278
+ projects,
279
+ entrypoints,
280
+ };
281
+ }
282
+
283
+ function normalizePagination(pagination) {
284
+ if (!isRecord(pagination)) return { nextCursor: null, previousCursor: null, limit: null, hasNext: false };
285
+ const nextCursor = text(pagination.next_cursor ?? pagination.nextCursor) || null;
286
+ const previousCursor = text(pagination.previous_cursor ?? pagination.previousCursor) || null;
287
+ const limit = nonNegativeInteger(pagination.limit);
288
+ return {
289
+ nextCursor,
290
+ previousCursor,
291
+ limit,
292
+ hasNext: pagination.has_next === true || pagination.hasNext === true || Boolean(nextCursor),
293
+ };
294
+ }
295
+
296
+ function isNormalHistoryPayload(payload) {
297
+ return payload?.contract_version === 'brainbase.judgment-history.v1'
298
+ || Array.isArray(payload?.records)
299
+ || isRecord(payload?.record);
300
+ }
301
+
101
302
  function isProof(value) {
102
303
  return isRecord(value)
103
304
  && text(value.intent_id)
@@ -140,18 +341,57 @@ function normalizeSectionItems(sections) {
140
341
  */
141
342
  export function normalizeJudgmentHistoryHome(payload) {
142
343
  if (!isRecord(payload)) return { status: 'invalid', reason: 'response_not_object' };
344
+ if (isNormalHistoryPayload(payload)) {
345
+ const rawStatus = text(payload.status).toLowerCase();
346
+ if (!['available', 'partial', 'unavailable', 'error'].includes(rawStatus)) {
347
+ return { mode: 'normal', status: 'invalid', reason: 'unsupported_status' };
348
+ }
349
+ const coverage = normalizeCoverage(payload.coverage, rawStatus);
350
+ if (rawStatus === 'unavailable' || rawStatus === 'error') {
351
+ return {
352
+ mode: 'normal',
353
+ status: rawStatus,
354
+ contractVersion: text(payload.contract_version) || 'brainbase.judgment-history.v1',
355
+ coverage,
356
+ filters: normalizeNormalFilters(payload.filters),
357
+ pagination: normalizePagination(payload.pagination),
358
+ records: [],
359
+ reason: text(payload.reason ?? coverage.reason) || null,
360
+ };
361
+ }
362
+ if (!Array.isArray(payload.records)) return { mode: 'normal', status: 'invalid', reason: 'records_missing' };
363
+ const records = payload.records.map(normalizeNormalRecord).filter(Boolean);
364
+ const invalidRecordCount = records.filter((record) => record.invalidFields.length > 0).length;
365
+ const invalidCount = payload.records.length - records.length + invalidRecordCount;
366
+ const sourceGaps = coverage.sources.filter((source) => ['partial', 'unavailable'].includes(source.status));
367
+ const status = rawStatus === 'partial' || coverage.complete === false || invalidCount > 0 || sourceGaps.length > 0 ? 'partial' : 'available';
368
+ const reason = text(payload.reason ?? coverage.reason)
369
+ || sourceGaps.find((source) => source.reason)?.reason
370
+ || (invalidCount > 0 ? 'record_invalid' : null);
371
+ return {
372
+ mode: 'normal',
373
+ status,
374
+ contractVersion: text(payload.contract_version) || 'brainbase.judgment-history.v1',
375
+ coverage: { ...coverage, complete: status === 'available' && coverage.complete !== false },
376
+ filters: normalizeNormalFilters(payload.filters),
377
+ pagination: normalizePagination(payload.pagination),
378
+ records,
379
+ invalidCount,
380
+ reason,
381
+ };
382
+ }
143
383
  if (payload.status === 'unavailable') {
144
- return { status: 'unavailable', root: text(payload.root) || null, reason: text(payload.reason) || 'judgment_journal_unavailable' };
384
+ return { mode: 'legacy', status: 'unavailable', root: text(payload.root) || null, reason: text(payload.reason) || 'judgment_journal_unavailable' };
145
385
  }
146
386
  if (payload.status !== 'available' && payload.status !== 'partial') {
147
- return { status: 'invalid', reason: 'unsupported_status' };
387
+ return { mode: 'legacy', status: 'invalid', reason: 'unsupported_status' };
148
388
  }
149
389
  const sectionResult = normalizeSectionItems(payload.sections);
150
- if (!sectionResult) return { status: 'invalid', reason: 'sections_missing' };
390
+ if (!sectionResult) return { mode: 'legacy', status: 'invalid', reason: 'sections_missing' };
151
391
  const rawCoverage = isRecord(payload.coverage) ? payload.coverage : {};
152
392
  const saved = nonNegativeInteger(rawCoverage.saved);
153
393
  const rejectedCount = nonNegativeInteger(rawCoverage.rejected);
154
- if (saved === null || rejectedCount === null) return { status: 'invalid', reason: 'coverage_incomplete' };
394
+ if (saved === null || rejectedCount === null) return { mode: 'legacy', status: 'invalid', reason: 'coverage_incomplete' };
155
395
  const coverageState = text(rawCoverage.status ?? rawCoverage.state).toLowerCase();
156
396
  const coverageMismatch = saved !== sectionResult.rawCount || rejectedCount !== (Array.isArray(payload.rejected) ? payload.rejected.length : 0);
157
397
  const status = payload.status === 'partial' || rawCoverage.complete === false || coverageState === 'partial' || coverageState === 'unknown'
@@ -162,6 +402,7 @@ export function normalizeJudgmentHistoryHome(payload) {
162
402
  ? payload.rejected.filter(isRecord).map((entry) => ({ file: text(entry.file) || null, reason: text(entry.reason) || 'record_rejected' }))
163
403
  : [];
164
404
  return {
405
+ mode: 'legacy',
165
406
  status,
166
407
  root: text(payload.root) || null,
167
408
  coverage: { ...normalizeCoverage(rawCoverage, status), saved, rejected: rejectedCount },
@@ -312,15 +553,148 @@ function isDelegated(proof) {
312
553
  return proof?.interruption?.resolution === 'continued_without_human' && Boolean(text(proof?.decision?.summary));
313
554
  }
314
555
 
556
+ function normalReferenceSearchText(reference) {
557
+ if (!isRecord(reference)) return '';
558
+ return [
559
+ reference.ref,
560
+ reference.kind,
561
+ reference.version,
562
+ reference.digest,
563
+ reference.why,
564
+ reference.usage,
565
+ reference.availability,
566
+ reference.label,
567
+ ].filter(Boolean).join(' ');
568
+ }
569
+
570
+ function normalRecordSearchText(record) {
571
+ const judgment = isRecord(record.judgment) ? record.judgment : {};
572
+ const execution = isRecord(record.execution) ? record.execution : {};
573
+ const alternatives = Array.isArray(record.alternatives) ? record.alternatives : [];
574
+ const references = Array.isArray(record.selectedReferences) ? record.selectedReferences : [];
575
+ return [
576
+ record.recordId,
577
+ record.entrypoint,
578
+ NORMAL_ENTRYPOINT_LABELS[record.entrypoint],
579
+ record.projectCode,
580
+ record.turnRef,
581
+ record.recordedAt,
582
+ judgment.status,
583
+ judgment.summary,
584
+ judgment.reason,
585
+ execution.status,
586
+ execution.result_summary,
587
+ execution.outcome_status,
588
+ ...record.missingFields,
589
+ ...references.map(normalReferenceSearchText),
590
+ ...alternatives.flatMap((alternative) => [alternative.label, alternative.evaluation, alternative.adoption, alternative.adopted]),
591
+ ].filter(Boolean).join(' ').toLocaleLowerCase('ja-JP');
592
+ }
593
+
594
+ function aggregateNormalJudgmentHistory(home, { now = new Date(), period = PERIODS.past30days, query = '' } = {}) {
595
+ const empty = {
596
+ mode: 'normal',
597
+ rows: [],
598
+ stats: {
599
+ judgments: null,
600
+ references: null,
601
+ equivalent: null,
602
+ outcomes: null,
603
+ judgmentCount: null,
604
+ referenceCount: null,
605
+ equivalentJudgments: null,
606
+ confirmedOutcomes: null,
607
+ },
608
+ visibleCount: 0,
609
+ totalCount: home.coverage?.total ?? (home.coverage?.complete === true && home.records.length === 0 ? 0 : null),
610
+ period: normalizedPeriod(period),
611
+ query: text(query),
612
+ status: home.status,
613
+ coverage: home.coverage ?? null,
614
+ rejected: home.rejected ?? [],
615
+ reason: home.reason ?? null,
616
+ pagination: home.pagination ?? { nextCursor: null, previousCursor: null, limit: null, hasNext: false },
617
+ filters: home.filters ?? null,
618
+ };
619
+ if (home.status !== 'available' && home.status !== 'partial') return empty;
620
+ const nowMs = normalizedNow(now);
621
+ const start = periodStart(normalizedPeriod(period), nowMs);
622
+ const needle = text(query).toLocaleLowerCase('ja-JP');
623
+ const rows = [];
624
+ for (const record of home.records ?? []) {
625
+ const recorded = timestamp(record.recordedAt);
626
+ if (recorded === null || recorded < start || recorded > nowMs) continue;
627
+ const searchText = normalRecordSearchText(record);
628
+ if (needle && !searchText.includes(needle)) continue;
629
+ rows.push({
630
+ mode: 'normal',
631
+ key: record.recordId,
632
+ recordId: record.recordId,
633
+ recordedAt: record.recordedAt,
634
+ record: record.record,
635
+ entrypoint: record.entrypoint,
636
+ projectCode: record.projectCode,
637
+ turnRef: record.turnRef,
638
+ judgment: record.judgment,
639
+ execution: record.execution,
640
+ missingFields: record.missingFields,
641
+ alternatives: record.alternatives,
642
+ references: record.selectedReferences,
643
+ searchText,
644
+ });
645
+ }
646
+ rows.sort((left, right) => {
647
+ const date = timestamp(right.recordedAt) - timestamp(left.recordedAt);
648
+ return date || right.recordId.localeCompare(left.recordId);
649
+ });
650
+ const references = new Map();
651
+ let referencesUnconfirmed = false;
652
+ for (const row of rows) {
653
+ if (row.references === null) {
654
+ referencesUnconfirmed = true;
655
+ continue;
656
+ }
657
+ for (const reference of row.references) {
658
+ const key = `${reference.ref}\u0000${reference.version ?? ''}\u0000${reference.digest ?? ''}`;
659
+ if (!references.has(key)) references.set(key, reference);
660
+ }
661
+ }
662
+ const metricsUnconfirmed = home.status === 'partial' && rows.length === 0;
663
+ const judgments = metricsUnconfirmed ? null : rows.length;
664
+ const referenceCount = metricsUnconfirmed || referencesUnconfirmed ? null : references.size;
665
+ const confirmedOutcomes = metricsUnconfirmed
666
+ ? null
667
+ : rows.filter((row) => text(row.execution?.outcome_status).toLowerCase() === 'confirmed').length;
668
+ return {
669
+ ...empty,
670
+ rows,
671
+ stats: {
672
+ judgments,
673
+ references: referenceCount,
674
+ equivalent: judgments,
675
+ outcomes: confirmedOutcomes,
676
+ delegated: judgments,
677
+ judgmentCount: judgments,
678
+ referenceCount,
679
+ equivalentJudgments: judgments,
680
+ confirmedOutcomes,
681
+ },
682
+ visibleCount: rows.length,
683
+ totalCount: home.coverage?.total ?? (home.coverage?.complete === true && home.records.length === 0 ? 0 : null),
684
+ };
685
+ }
686
+
315
687
  /**
316
688
  * Project a home read model into one newest-first judgment history.
317
689
  * `now`, `period` and `query` are options so the same data can be tested and
318
690
  * rendered consistently by a host.
319
691
  */
320
692
  export function aggregateJudgmentHistory(home, { now = new Date(), period = PERIODS.past30days, query = '' } = {}) {
321
- const normalized = home?.items && (home.status === 'available' || home.status === 'partial')
693
+ const normalized = home?.mode === 'normal'
694
+ || (home?.items && (home.status === 'available' || home.status === 'partial'))
322
695
  ? home
323
696
  : normalizeJudgmentHistoryHome(home);
697
+ if (normalized.mode === 'normal') return aggregateNormalJudgmentHistory(normalized, { now, period, query });
324
698
  const empty = {
325
699
  rows: [],
326
700
  stats: {
@@ -427,7 +801,8 @@ function formatDate(value) {
427
801
  }
428
802
 
429
803
  function makeReferenceSummary(doc, references) {
430
- if (!references.length) return makeWorkspaceElement(doc, 'span', { className: 'bb-jh-unrecorded', text: '参照は未記録' });
804
+ if (references === null || references === undefined) return makeWorkspaceElement(doc, 'span', { className: 'bb-jh-unrecorded', text: '参照は未記録' });
805
+ if (!references.length) return makeWorkspaceElement(doc, 'span', { className: 'bb-jh-unrecorded', text: '参照はありません' });
431
806
  const wrap = makeWorkspaceElement(doc, 'span', { className: 'bb-jh-reference-summary' });
432
807
  const first = references.slice(0, 2).map((ref) => ref.label || ref.id).join('、');
433
808
  wrap.append(makeWorkspaceElement(doc, 'strong', { text: first }));
@@ -436,6 +811,14 @@ function makeReferenceSummary(doc, references) {
436
811
  }
437
812
 
438
813
  function makeDecisionSummary(doc, row) {
814
+ if (row.mode === 'normal') {
815
+ const summary = text(row.judgment?.summary);
816
+ const status = text(row.judgment?.status);
817
+ const wrap = makeWorkspaceElement(doc, 'span', { className: 'bb-jh-decision is-normal' });
818
+ wrap.append(makeWorkspaceElement(doc, 'strong', { text: summary || '判断内容は未記録' }));
819
+ wrap.append(makeWorkspaceElement(doc, 'small', { text: `${NORMAL_ENTRYPOINT_LABELS[row.entrypoint] ?? '入口不明'}${status ? `・${status}` : ''}` }));
820
+ return wrap;
821
+ }
439
822
  const summary = text(row.proof?.decision?.summary);
440
823
  const wrap = makeWorkspaceElement(doc, 'span', { className: `bb-jh-decision${row.delegated ? ' is-delegated' : ''}` });
441
824
  wrap.append(makeWorkspaceElement(doc, 'strong', { text: summary || (row.proof?.interruption?.resolution === 'continued_without_human' ? '判断内容未記録' : '本人に確認') }));
@@ -445,13 +828,20 @@ function makeDecisionSummary(doc, row) {
445
828
  }
446
829
 
447
830
  function appendReferenceList(doc, references) {
448
- if (!references.length) return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '参照は記録されていません。' });
831
+ if (references === null || references === undefined) return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '参照は記録されていません。' });
832
+ if (!references.length) return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '参照はありません。' });
449
833
  const list = makeWorkspaceElement(doc, 'ul', { className: 'bb-jh-reference-list' });
450
834
  for (const ref of references) {
451
835
  const item = makeWorkspaceElement(doc, 'li');
452
836
  item.append(makeWorkspaceElement(doc, 'strong', { text: ref.label || ref.id }));
453
- item.append(makeWorkspaceElement(doc, 'code', { text: `${ref.id}${ref.version ? `@${ref.version}` : ''}` }));
837
+ item.append(makeWorkspaceElement(doc, 'code', { text: `${ref.ref ?? ref.id}${ref.version ? `@${ref.version}` : ''}${ref.digest ? `#${ref.digest}` : ''}` }));
454
838
  if (ref.application && ref.application !== ref.label) item.append(makeWorkspaceElement(doc, 'span', { text: ref.application }));
839
+ if (ref.availability && ref.availability !== 'recorded') {
840
+ const availability = ref.availability === 'permission_denied' ? '権限がありません' : ref.availability === 'unavailable' ? '現在は参照できません' : '参照状態は未確認';
841
+ item.append(makeWorkspaceElement(doc, 'span', { className: 'bb-jh-reference-availability', text: availability }));
842
+ }
843
+ if (ref.why) item.append(makeWorkspaceElement(doc, 'span', { text: `理由: ${ref.why}` }));
844
+ if (ref.usage) item.append(makeWorkspaceElement(doc, 'span', { text: `用途: ${ref.usage}` }));
455
845
  const alternateLabels = (ref.labels ?? []).filter((label) => label && label !== ref.label);
456
846
  if (alternateLabels.length) item.append(makeWorkspaceElement(doc, 'span', { text: `別名: ${alternateLabels.join('、')}` }));
457
847
  list.append(item);
@@ -470,6 +860,49 @@ function appendSupplement(doc, proof) {
470
860
  ]);
471
861
  }
472
862
 
863
+ function appendNormalAlternatives(doc, alternatives) {
864
+ if (alternatives === null || alternatives === undefined) {
865
+ return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '案は記録されていません。' });
866
+ }
867
+ if (!alternatives.length) return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '保存された案はありません。' });
868
+ const list = makeWorkspaceElement(doc, 'ul', { className: 'bb-jh-alternative-list' });
869
+ for (const alternative of alternatives) {
870
+ const item = makeWorkspaceElement(doc, 'li');
871
+ item.append(makeWorkspaceElement(doc, 'strong', { text: alternative.label || '案の名称は未記録' }));
872
+ if (alternative.evaluation) item.append(makeWorkspaceElement(doc, 'span', { text: `評価: ${alternative.evaluation}` }));
873
+ if (alternative.adoption) item.append(makeWorkspaceElement(doc, 'span', { text: `採用状態: ${alternative.adoption}` }));
874
+ else if (alternative.adopted === true) item.append(makeWorkspaceElement(doc, 'span', { text: '採用状態: 採用' }));
875
+ else if (alternative.adopted === false) item.append(makeWorkspaceElement(doc, 'span', { text: '採用状態: 不採用' }));
876
+ else if (Object.prototype.hasOwnProperty.call(alternative, 'adopted')) item.append(makeWorkspaceElement(doc, 'span', { text: '採用状態: 記録なし' }));
877
+ list.append(item);
878
+ }
879
+ return list;
880
+ }
881
+
882
+ function normalExecutionLabel(execution) {
883
+ const status = text(execution?.status).toLowerCase();
884
+ const labels = { pending: '実行待ち', completed: '実行済み', failed: '実行失敗', held: '保留', cancelled: '取消', unknown: '実行状態未確認' };
885
+ return labels[status] ?? '実行状態未確認';
886
+ }
887
+
888
+ function normalOutcomeLabel(execution) {
889
+ const status = text(execution?.outcome_status).toLowerCase();
890
+ const labels = { confirmed: '結果確認済み', unconfirmed: '結果未確認', unknown: '結果未確認' };
891
+ return labels[status] ?? '結果未確認';
892
+ }
893
+
894
+ function appendNormalExecution(doc, execution) {
895
+ return workspaceDefinition(doc, [
896
+ ['実行', [normalExecutionLabel(execution), text(execution?.result_summary)]],
897
+ ['結果', normalOutcomeLabel(execution)],
898
+ ]);
899
+ }
900
+
901
+ function appendNormalMissingFields(doc, missingFields) {
902
+ if (!Array.isArray(missingFields) || missingFields.length === 0) return null;
903
+ return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-missing-fields', text: `未記録: ${missingFields.join('、')}` });
904
+ }
905
+
473
906
  function appendTextParts(doc, values) {
474
907
  const wrap = makeWorkspaceElement(doc, 'div');
475
908
  for (const value of values) {
@@ -478,7 +911,209 @@ function appendTextParts(doc, values) {
478
911
  return wrap;
479
912
  }
480
913
 
481
- function renderRail(doc, rail, row, onClose) {
914
+ function normalFeedbackContentSummary(content) {
915
+ if (!isRecord(content)) return '内容は未記録';
916
+ return text(content.summary ?? content.result_summary ?? content.message ?? content.reason)
917
+ || canonicalJson(content)
918
+ || '内容は未記録';
919
+ }
920
+
921
+ function normalReferencePayload(record, event, applicabilityReason = '', eventRef = null) {
922
+ const payload = {
923
+ ref: `judgment-history:${record.recordId}`,
924
+ record_id: record.recordId,
925
+ why: text(applicabilityReason) || null,
926
+ };
927
+ if (event) {
928
+ payload.event_ref = eventRef;
929
+ payload.event_id = event.eventId;
930
+ payload.kind = event.kind;
931
+ }
932
+ return payload;
933
+ }
934
+
935
+ function normalReferencePayloadText(record, event, applicabilityReason = '', eventRef = null) {
936
+ return JSON.stringify(normalReferencePayload(record, event, applicabilityReason, eventRef), null, 2);
937
+ }
938
+
939
+ function appendNormalReferenceExport(doc, record, event, { onCopyReference } = {}) {
940
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-reference-export' });
941
+ const refLabel = event
942
+ ? `${record.recordId} / ${event.eventId}`
943
+ : `${record.recordId} / イベントIDは未記録`;
944
+ wrap.append(makeWorkspaceElement(doc, 'code', { text: refLabel }));
945
+ if (event) wrap.append(makeWorkspaceElement(doc, 'p', { text: `種別: ${FEEDBACK_KIND_LABELS[event.kind] ?? event.kind}・${normalFeedbackContentSummary(event.content)}` }));
946
+ const reason = makeWorkspaceElement(doc, 'textarea', {
947
+ className: 'bb-jh-applicability-reason',
948
+ attrs: {
949
+ rows: 2,
950
+ 'aria-label': `適用理由${event ? `(${event.eventId})` : ''}`,
951
+ placeholder: '次の判断で使う理由を記入',
952
+ },
953
+ });
954
+ reason.value = '';
955
+ wrap.append(makeWorkspaceElement(doc, 'label', { text: '適用理由' }), reason);
956
+ const preview = makeWorkspaceElement(doc, 'pre', {
957
+ className: 'bb-jh-reference-payload',
958
+ text: normalReferencePayloadText(record, event),
959
+ });
960
+ const status = makeWorkspaceElement(doc, 'span', { className: 'bb-jh-copy-status', text: 'この情報を次の判断へ渡せます。' });
961
+ wrap.append(preview, workspaceButton(doc, {
962
+ text: '参照情報をコピー',
963
+ variant: 'quiet',
964
+ attrs: { 'data-action': 'copy-judgment-reference' },
965
+ onClick: () => {
966
+ if (typeof onCopyReference === 'function') onCopyReference({ record, event, reason, preview, status });
967
+ else status.textContent = '参照情報を選択してコピーできます。';
968
+ },
969
+ }), status);
970
+ return wrap;
971
+ }
972
+
973
+ function appendNormalFeedbackEvents(doc, record, { onCopyReference } = {}) {
974
+ const events = Array.isArray(record.feedbackEvents) ? record.feedbackEvents : [];
975
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-feedback-events' });
976
+ if (!events.length) {
977
+ wrap.append(makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '追記された結果・訂正・フィードバックはありません。' }));
978
+ wrap.append(appendNormalReferenceExport(doc, record, null, { onCopyReference }));
979
+ return wrap;
980
+ }
981
+ for (const event of events) {
982
+ const item = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-feedback-event' });
983
+ item.append(makeWorkspaceElement(doc, 'strong', { text: FEEDBACK_KIND_LABELS[event.kind] ?? event.kind }));
984
+ item.append(makeWorkspaceElement(doc, 'code', { text: `record_id=${event.recordId} event_id=${event.eventId}` }));
985
+ item.append(makeWorkspaceElement(doc, 'p', { text: normalFeedbackContentSummary(event.content) }));
986
+ item.append(appendNormalReferenceExport(doc, record, event, { onCopyReference }));
987
+ wrap.append(item);
988
+ }
989
+ return wrap;
990
+ }
991
+
992
+ function feedbackStatusText(draft) {
993
+ if (!draft) return '';
994
+ if (draft.phase === 'submitting') return '送信中です。保存状態はまだ未確認です。';
995
+ if (draft.phase === 'readback') return '正本を読み戻して保存状態を確認しています。';
996
+ if (draft.phase === 'saved') return '保存済み(正本で確認しました)。';
997
+ if (draft.phase === 'unavailable') return `利用できません。${draft.status || '正本に接続できないため、保存状態を確認できません。'}`;
998
+ if (draft.phase === 'unconfirmed') return `未確認。${draft.status || 'POST後の正本読戻しで一致を確認できませんでした。'}`;
999
+ if (draft.phase === 'error') return `送信できません。保存状態は未確認です。${draft.status ? ` ${draft.status}` : ''}`;
1000
+ return draft.status || '送信前です。';
1001
+ }
1002
+
1003
+ function appendNormalFeedbackComposer(doc, record, {
1004
+ detailReady = false,
1005
+ detailPhase = 'idle',
1006
+ draft,
1007
+ onKindChange,
1008
+ onSummaryChange,
1009
+ onOutcomeStatusChange,
1010
+ onSubmitFeedback,
1011
+ } = {}) {
1012
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-feedback-composer' });
1013
+ if (!detailReady) {
1014
+ const message = detailPhase === 'loading'
1015
+ ? '正本の詳細を確認しています。'
1016
+ : '正本の詳細を確認できないため、追記は利用できません。';
1017
+ wrap.append(makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: message }));
1018
+ return wrap;
1019
+ }
1020
+ const kindLabel = makeWorkspaceElement(doc, 'label', { text: '追記の種別' });
1021
+ const kind = makeWorkspaceElement(doc, 'select', { attrs: { 'aria-label': '追記の種別' } });
1022
+ for (const value of FEEDBACK_KINDS) {
1023
+ kind.append(makeWorkspaceElement(doc, 'option', {
1024
+ text: FEEDBACK_KIND_LABELS[value],
1025
+ attrs: { value },
1026
+ }));
1027
+ }
1028
+ kind.value = draft?.kind ?? 'feedback';
1029
+ kind.addEventListener('change', (event) => onKindChange?.(event?.target?.value ?? kind.value));
1030
+ const summaryLabel = makeWorkspaceElement(doc, 'label', { text: '内容(保存公開フィールド)' });
1031
+ const summary = makeWorkspaceElement(doc, 'textarea', {
1032
+ className: 'bb-jh-feedback-content',
1033
+ attrs: { rows: 3, 'aria-label': '追記の内容', placeholder: '結果・訂正・フィードバックを記入' },
1034
+ });
1035
+ summary.value = draft?.summary ?? '';
1036
+ summary.addEventListener('input', (event) => onSummaryChange?.(event?.target?.value ?? summary.value));
1037
+ const outcomeLabel = makeWorkspaceElement(doc, 'label', { text: '結果の確認状態(明示する場合)' });
1038
+ const outcomeStatus = makeWorkspaceElement(doc, 'select', { attrs: { 'aria-label': '結果の確認状態' } });
1039
+ outcomeStatus.append(makeWorkspaceElement(doc, 'option', { text: '記録しない', attrs: { value: '' } }));
1040
+ for (const value of ['unknown', 'unconfirmed', 'confirmed']) {
1041
+ const label = { unknown: '未確認', unconfirmed: '結果未確認', confirmed: '結果確認済み' }[value];
1042
+ outcomeStatus.append(makeWorkspaceElement(doc, 'option', { text: label, attrs: { value } }));
1043
+ }
1044
+ outcomeStatus.value = draft?.outcomeStatus ?? '';
1045
+ outcomeStatus.addEventListener('change', (event) => onOutcomeStatusChange?.(event?.target?.value ?? outcomeStatus.value));
1046
+ const eventId = makeWorkspaceElement(doc, 'code', { text: `event_id=${draft?.eventId ?? '未発行'}` });
1047
+ const phase = draft?.phase ?? 'idle';
1048
+ const busy = phase === 'submitting' || phase === 'readback';
1049
+ const button = workspaceButton(doc, {
1050
+ text: phase === 'saved' ? '保存済み' : '結果・訂正・フィードバックを追記',
1051
+ variant: 'primary',
1052
+ disabled: busy || phase === 'saved',
1053
+ attrs: { 'data-action': 'submit-judgment-feedback' },
1054
+ onClick: () => onSubmitFeedback?.({ kind, summary, outcomeStatus, eventId: draft?.eventId }),
1055
+ });
1056
+ const status = makeWorkspaceElement(doc, 'p', {
1057
+ className: `bb-jh-feedback-status is-${phase}`,
1058
+ text: feedbackStatusText(draft),
1059
+ attrs: { role: phase === 'error' || phase === 'unavailable' ? 'alert' : 'status' },
1060
+ });
1061
+ wrap.append(kindLabel, kind, summaryLabel, summary, outcomeLabel, outcomeStatus, eventId, button, status);
1062
+ return wrap;
1063
+ }
1064
+
1065
+ function renderNormalRail(doc, rail, row, detail, onClose, options = {}) {
1066
+ if (!rail) return;
1067
+ rail.replaceChildren();
1068
+ const record = detail ?? row;
1069
+ rail.append(workspaceRailHead(doc, {
1070
+ kicker: `${NORMAL_ENTRYPOINT_LABELS[record.entrypoint] ?? '入口不明'}の判断`,
1071
+ title: text(record.judgment?.summary) || '判断内容は未記録',
1072
+ sub: formatDate(record.recordedAt),
1073
+ }));
1074
+ if (typeof onClose === 'function') {
1075
+ rail.append(workspaceButton(doc, {
1076
+ text: '詳細を閉じる',
1077
+ variant: 'quiet',
1078
+ onClick: onClose,
1079
+ attrs: { 'aria-label': '判断の詳細を閉じる' },
1080
+ }));
1081
+ }
1082
+ rail.append(workspaceRailBlock(doc, {
1083
+ title: '使った参照(当時の記録)',
1084
+ content: appendReferenceList(doc, record.selectedReferences),
1085
+ }));
1086
+ rail.append(workspaceRailBlock(doc, {
1087
+ title: '判断の理由',
1088
+ content: appendTextParts(doc, [text(record.judgment?.reason) || '理由は記録されていません。']),
1089
+ }));
1090
+ rail.append(workspaceRailBlock(doc, {
1091
+ title: '保存された案',
1092
+ content: appendNormalAlternatives(doc, record.alternatives),
1093
+ }));
1094
+ rail.append(workspaceRailBlock(doc, {
1095
+ title: '実行と結果',
1096
+ content: appendNormalExecution(doc, record.execution),
1097
+ }));
1098
+ rail.append(workspaceRailBlock(doc, {
1099
+ title: '結果・訂正・フィードバック',
1100
+ content: appendNormalFeedbackEvents(doc, record, { onCopyReference: options.onCopyReference }),
1101
+ }));
1102
+ rail.append(workspaceRailBlock(doc, {
1103
+ title: '次の判断に渡す参照',
1104
+ content: appendNormalFeedbackComposer(doc, record, options),
1105
+ }));
1106
+ const metadata = workspaceDefinition(doc, [
1107
+ ['記録ID', record.recordId],
1108
+ ['プロジェクト', record.projectCode],
1109
+ ['Turn', record.turnRef],
1110
+ ]);
1111
+ const missing = appendNormalMissingFields(doc, record.missingFields);
1112
+ if (missing) metadata.append(missing);
1113
+ rail.append(workspaceRailBlock(doc, { title: '記録の範囲', content: metadata }));
1114
+ }
1115
+
1116
+ function renderRail(doc, rail, row, detail, onClose, options = {}) {
482
1117
  if (!rail) return;
483
1118
  rail.replaceChildren();
484
1119
  if (!row) {
@@ -489,6 +1124,10 @@ function renderRail(doc, rail, row, onClose) {
489
1124
  }));
490
1125
  return;
491
1126
  }
1127
+ if (row.mode === 'normal') {
1128
+ renderNormalRail(doc, rail, row, detail, onClose, options);
1129
+ return;
1130
+ }
492
1131
  const proof = row.proof;
493
1132
  rail.append(workspaceRailHead(doc, {
494
1133
  kicker: row.delegated ? 'Brainbaseの判断' : '判断履歴',
@@ -531,9 +1170,16 @@ function statusNotice(doc, phase, error, home, onRetry) {
531
1170
  tone: 'danger',
532
1171
  role: 'alert',
533
1172
  });
1173
+ if (home?.status === 'error') return workspaceNotice(doc, {
1174
+ label: '取得に失敗',
1175
+ text: ['判断の記録を確認できません。データが0件とは確認できていません。', workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
1176
+ tone: 'danger',
1177
+ role: 'alert',
1178
+ });
1179
+ const sourceSummary = normalSourcesSummary(home);
534
1180
  if (home?.status === 'unavailable') return workspaceNotice(doc, {
535
1181
  label: '未接続',
536
- text: ['保存された判断に接続できません。データが0件とは確認できていません。', workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
1182
+ text: [`保存された判断に接続できません${home.coverage?.storage ? `(${normalStorageLabel(home.coverage.storage)})` : ''}。データが0件とは確認できていません。`, sourceSummary ? `取得元: ${sourceSummary}` : null, workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
537
1183
  tone: 'warning',
538
1184
  role: 'alert',
539
1185
  });
@@ -545,7 +1191,7 @@ function statusNotice(doc, phase, error, home, onRetry) {
545
1191
  });
546
1192
  if (home?.status === 'partial') return workspaceNotice(doc, {
547
1193
  label: '取得できた範囲',
548
- text: [`読み取れた判断だけを表示しています。${home.coverage?.rejected ? `読み取れなかった記録は${home.coverage.rejected}件あります。` : ''}`, workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
1194
+ text: [`読み取れた判断だけを表示しています。${home.coverage?.storage ? `(${normalStorageLabel(home.coverage.storage)})` : ''}${home.coverage?.reason ? ` ${normalReasonLabel(home.coverage.reason)}` : ''}${home.coverage?.rejected ? ` 読み取れなかった記録は${home.coverage.rejected}件あります。` : ''}`, sourceSummary ? `取得元: ${sourceSummary}` : null, workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
549
1195
  tone: 'warning',
550
1196
  role: 'status',
551
1197
  });
@@ -577,12 +1223,108 @@ function createSearch(doc, value, onInput) {
577
1223
  return label;
578
1224
  }
579
1225
 
1226
+ function normalStorageLabel(storage) {
1227
+ if (storage === 'local') return '端末';
1228
+ if (storage === 'server') return 'サーバー';
1229
+ return storage || '接続先不明';
1230
+ }
1231
+
1232
+ function normalReasonLabel(reason) {
1233
+ const labels = {
1234
+ unconnected: '未接続',
1235
+ mana_not_connected: '未接続(Mana)',
1236
+ company_os_native_source_not_connected: 'Company OSの判断実行履歴が未接続',
1237
+ journal_unavailable: '判断記録がありません',
1238
+ journal_unreadable: '判断記録を読み取れません',
1239
+ };
1240
+ return labels[reason] ?? reason;
1241
+ }
1242
+
1243
+ function normalSourceStatusLabel(status) {
1244
+ const labels = {
1245
+ available: '接続済み',
1246
+ partial: '一部取得',
1247
+ unavailable: '未接続',
1248
+ };
1249
+ return labels[status] ?? '未確認';
1250
+ }
1251
+
1252
+ function normalSourcesSummary(home) {
1253
+ if (home?.mode !== 'normal' || !Array.isArray(home.coverage?.sources) || home.coverage.sources.length === 0) return '';
1254
+ return home.coverage.sources.map((source) => {
1255
+ const entrypoint = NORMAL_ENTRYPOINT_LABELS[normalEntrypoint(source.entrypoint)] ?? '入口不明';
1256
+ const reason = source.reason ? `(${normalReasonLabel(source.reason)})` : '';
1257
+ return `${entrypoint}: ${normalSourceStatusLabel(source.status)}${reason}`;
1258
+ }).join('、');
1259
+ }
1260
+
1261
+ function createFilterSelect(doc, { label, value, options, onChange, className = '' }) {
1262
+ const wrap = makeWorkspaceElement(doc, 'label', { className: `bb-jh-filter${className ? ` ${className}` : ''}` });
1263
+ wrap.append(makeWorkspaceElement(doc, 'span', { text: label }));
1264
+ const select = makeWorkspaceElement(doc, 'select', { attrs: { 'aria-label': label } });
1265
+ const current = value ?? '';
1266
+ const choices = [{ value: '', label: 'すべて' }, ...options];
1267
+ for (const option of choices) {
1268
+ const optionElement = makeWorkspaceElement(doc, 'option', { text: option.label, attrs: { value: option.value, selected: option.value === current ? 'selected' : undefined } });
1269
+ optionElement.value = option.value;
1270
+ select.append(optionElement);
1271
+ }
1272
+ select.value = current;
1273
+ select.addEventListener('change', (event) => onChange(event?.target?.value ?? select.value ?? ''));
1274
+ wrap.append(select);
1275
+ return wrap;
1276
+ }
1277
+
1278
+ function normalFilterControls(doc, home, state, onChange) {
1279
+ const filters = home?.filters ?? {};
1280
+ const projects = [...(filters.projects ?? [])];
1281
+ const knownProjects = (home.records ?? []).map((record) => record.projectCode).filter(Boolean);
1282
+ for (const project of knownProjects) {
1283
+ if (!projects.some((option) => option.value === project)) projects.push({ value: project, label: project });
1284
+ }
1285
+ const entrypoints = [...(filters.entrypoints ?? [])];
1286
+ const knownEntrypoints = (home.records ?? []).map((record) => record.entrypoint).filter(Boolean);
1287
+ for (const entrypoint of [...NORMAL_ENTRYPOINTS, ...knownEntrypoints]) {
1288
+ if (!entrypoints.some((option) => option.value === entrypoint)) {
1289
+ entrypoints.push({ value: entrypoint, label: NORMAL_ENTRYPOINT_LABELS[entrypoint] ?? entrypoint });
1290
+ }
1291
+ }
1292
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-normal-filters', attrs: { 'aria-label': '絞り込み' } });
1293
+ wrap.append(createFilterSelect(doc, {
1294
+ label: 'プロジェクト',
1295
+ value: state.project,
1296
+ options: projects,
1297
+ onChange: (project) => onChange({ project: project || null }),
1298
+ }));
1299
+ wrap.append(createFilterSelect(doc, {
1300
+ label: '入口',
1301
+ value: state.entrypoint,
1302
+ options: entrypoints,
1303
+ onChange: (entrypoint) => onChange({ entrypoint: entrypoint || null }),
1304
+ }));
1305
+ return wrap;
1306
+ }
1307
+
1308
+ function paginationControls(doc, aggregate, onNext) {
1309
+ if (aggregate?.mode !== 'normal') return null;
1310
+ const pagination = aggregate?.pagination;
1311
+ const total = aggregate?.totalCount;
1312
+ if (!pagination?.hasNext && total === null) return null;
1313
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-pagination' });
1314
+ const count = total === null ? '同じ範囲の件数は未確認' : `同じ範囲の件数: ${total}件`;
1315
+ wrap.append(makeWorkspaceElement(doc, 'span', { className: 'bb-jh-pagination-count', text: count }));
1316
+ if (pagination?.hasNext) {
1317
+ wrap.append(workspaceButton(doc, { text: '次のページ', variant: 'quiet', onClick: onNext, attrs: { 'data-action': 'next-page' } }));
1318
+ }
1319
+ return wrap;
1320
+ }
1321
+
580
1322
  function makeRows(doc, aggregate, selectedKey, onSelect) {
581
1323
  return aggregate.rows.map((row) => ({
582
1324
  key: row.key,
583
- label: `${formatDate(row.recordedAt)} ${text(row.proof?.decision?.summary) || '判断履歴'}`,
1325
+ label: `${formatDate(row.recordedAt)} ${row.mode === 'normal' ? text(row.judgment?.summary) || '判断履歴' : text(row.proof?.decision?.summary) || '判断履歴'}`,
584
1326
  selected: row.key === selectedKey,
585
- className: `bb-jh-row${row.delegated ? ' is-delegated' : ' is-returned'}`,
1327
+ className: `bb-jh-row${row.mode === 'normal' ? ' is-normal' : row.delegated ? ' is-delegated' : ' is-returned'}`,
586
1328
  onSelect,
587
1329
  cells: [
588
1330
  makeWorkspaceElement(doc, 'span', { className: 'bb-jh-date', text: formatDate(row.recordedAt) }),
@@ -592,14 +1334,14 @@ function makeRows(doc, aggregate, selectedKey, onSelect) {
592
1334
  }));
593
1335
  }
594
1336
 
595
- /** Create the shared, read-only judgment history controller. */
1337
+ /** Create the shared judgment history controller and canonical feedback adapter. */
596
1338
  export function createJudgmentHistoryUI({
597
1339
  root,
598
1340
  rail = null,
599
1341
  document: explicitDocument,
600
1342
  fetcher,
601
1343
  token,
602
- basePath = '/api/value-proofs',
1344
+ basePath = '/api/judgment-history',
603
1345
  page,
604
1346
  autoLoad = true,
605
1347
  pageHeader = true,
@@ -611,6 +1353,7 @@ export function createJudgmentHistoryUI({
611
1353
  ? fetcher
612
1354
  : typeof globalThis.fetch === 'function' ? (path, init) => globalThis.fetch(path, init) : null;
613
1355
  const normalizedBasePath = String(basePath).replace(/\/+$/u, '');
1356
+ const legacyBasePath = normalizedBasePath === '/api/judgment-history' ? '/api/value-proofs' : null;
614
1357
  const readNow = () => (typeof now === 'function' ? now() : now);
615
1358
  const state = {
616
1359
  phase: autoLoad ? 'loading' : 'idle',
@@ -619,9 +1362,19 @@ export function createJudgmentHistoryUI({
619
1362
  aggregate: null,
620
1363
  period: PERIODS.past30days,
621
1364
  query: '',
1365
+ project: null,
1366
+ entrypoint: null,
1367
+ cursor: null,
1368
+ limit: 50,
622
1369
  selectedKey: null,
1370
+ details: new Map(),
1371
+ detailPhase: 'idle',
1372
+ detailError: null,
1373
+ activeBasePath: normalizedBasePath,
1374
+ feedbackDrafts: new Map(),
623
1375
  disposed: false,
624
1376
  generation: 0,
1377
+ detailGeneration: 0,
625
1378
  focusSearch: false,
626
1379
  lastFocusedKey: null,
627
1380
  pendingDecisionId: null,
@@ -631,6 +1384,8 @@ export function createJudgmentHistoryUI({
631
1384
  if (!state.selectedKey || state.disposed) return false;
632
1385
  state.lastFocusedKey = state.selectedKey;
633
1386
  state.selectedKey = null;
1387
+ state.detailPhase = 'idle';
1388
+ state.detailError = null;
634
1389
  render();
635
1390
  return true;
636
1391
  }
@@ -650,6 +1405,229 @@ export function createJudgmentHistoryUI({
650
1405
  };
651
1406
  }
652
1407
 
1408
+ function newFeedbackEventId() {
1409
+ try {
1410
+ if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
1411
+ } catch {
1412
+ // The fallback below is sufficient for a retry token when Web Crypto is unavailable.
1413
+ }
1414
+ return `judgment-history-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1415
+ }
1416
+
1417
+ function feedbackDraft(recordId) {
1418
+ if (!recordId) return null;
1419
+ let draft = state.feedbackDrafts.get(recordId);
1420
+ if (!draft) {
1421
+ draft = {
1422
+ recordId,
1423
+ eventId: newFeedbackEventId(),
1424
+ kind: 'feedback',
1425
+ summary: '',
1426
+ outcomeStatus: '',
1427
+ phase: 'idle',
1428
+ status: '',
1429
+ };
1430
+ state.feedbackDrafts.set(recordId, draft);
1431
+ }
1432
+ return draft;
1433
+ }
1434
+
1435
+ function updateFeedbackDraft(recordId, changes) {
1436
+ const current = feedbackDraft(recordId);
1437
+ if (!current || !isRecord(changes)) return current;
1438
+ const next = { ...current, ...changes };
1439
+ state.feedbackDrafts.set(recordId, next);
1440
+ return next;
1441
+ }
1442
+
1443
+ function feedbackReadbackMatches(detail, expected) {
1444
+ if (!detail || !expected) return false;
1445
+ const events = Array.isArray(detail.feedbackEvents) ? detail.feedbackEvents : [];
1446
+ return events.some((event) => event.recordId === expected.recordId
1447
+ && event.eventId === expected.eventId
1448
+ && event.kind === expected.kind
1449
+ && canonicalJson(event.content) === canonicalJson(expected.content));
1450
+ }
1451
+
1452
+ function responseFeedbackReadback(payload, expected) {
1453
+ if (!isRecord(payload) || !expected) return false;
1454
+ const candidate = payload.event
1455
+ ?? payload.feedback_event
1456
+ ?? (isRecord(payload.feedback) ? payload.feedback : null)
1457
+ ?? (text(payload.event_id) ? payload : null);
1458
+ const event = normalizeFeedbackEvent(candidate, expected.recordId);
1459
+ if (!event) return false;
1460
+ const storageStatus = text(payload.storage_status ?? payload.storage?.status ?? payload.save_status).toLowerCase();
1461
+ const saved = payload.saved === true || event.saved === true || ['saved', 'stored', 'idempotent', 'confirmed'].includes(storageStatus)
1462
+ || ['saved', 'stored', 'idempotent', 'confirmed'].includes(event.storageStatus)
1463
+ || text(payload.status).toLowerCase() === 'saved';
1464
+ return saved
1465
+ && event.recordId === expected.recordId
1466
+ && event.eventId === expected.eventId
1467
+ && event.kind === expected.kind
1468
+ && canonicalJson(event.content) === canonicalJson(expected.content);
1469
+ }
1470
+
1471
+ async function feedbackReference(record, event) {
1472
+ if (!record || !event) return null;
1473
+ try {
1474
+ const subtle = globalThis.crypto?.subtle;
1475
+ if (!subtle || typeof subtle.digest !== 'function' || typeof TextEncoder !== 'function') return null;
1476
+ const bytes = await subtle.digest('SHA-256', new TextEncoder().encode(event.eventId));
1477
+ const hash = [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
1478
+ return `judgment-history-feedback:${record.recordId}:${hash}`;
1479
+ } catch {
1480
+ return null;
1481
+ }
1482
+ }
1483
+
1484
+ async function copyReference({ record, event, reason, preview, status } = {}) {
1485
+ if (!record || !preview || !status) return false;
1486
+ const applicabilityReason = reason?.value ?? '';
1487
+ preview.textContent = normalReferencePayloadText(record, event, applicabilityReason);
1488
+ const eventRef = await feedbackReference(record, event);
1489
+ const serialized = normalReferencePayloadText(record, event, applicabilityReason, eventRef);
1490
+ preview.textContent = serialized;
1491
+ try {
1492
+ const clipboard = globalThis.navigator?.clipboard;
1493
+ if (!clipboard || typeof clipboard.writeText !== 'function') throw new Error('clipboard_unavailable');
1494
+ await clipboard.writeText(serialized);
1495
+ status.textContent = '参照情報をコピーしました。';
1496
+ return true;
1497
+ } catch {
1498
+ status.textContent = '参照情報を表示しました。選択してコピーできます。';
1499
+ return false;
1500
+ }
1501
+ }
1502
+
1503
+ async function submitFeedback(row, controls) {
1504
+ if (!row || row.mode !== 'normal' || !controls || !request || state.disposed) return false;
1505
+ const current = feedbackDraft(row.recordId);
1506
+ const kind = feedbackKind(controls.kind?.value ?? current?.kind) ?? 'feedback';
1507
+ const summary = text(controls.summary?.value ?? current?.summary);
1508
+ const outcomeStatus = text(controls.outcomeStatus?.value ?? current?.outcomeStatus).toLowerCase();
1509
+ if (!summary) {
1510
+ updateFeedbackDraft(row.recordId, { kind, summary, outcomeStatus: '', phase: 'error', status: '内容を入力してください。' });
1511
+ render();
1512
+ return false;
1513
+ }
1514
+ const acceptedOutcomeStatuses = new Set(['unknown', 'unconfirmed', 'confirmed']);
1515
+ const content = { summary };
1516
+ if (acceptedOutcomeStatuses.has(outcomeStatus)) content.outcome_status = outcomeStatus;
1517
+ const displayedEventId = text(controls.eventId?.textContent);
1518
+ const eventId = displayedEventId.startsWith('event_id=')
1519
+ ? displayedEventId.slice('event_id='.length)
1520
+ : displayedEventId || current?.eventId || newFeedbackEventId();
1521
+ const expected = { recordId: row.recordId, eventId, kind, content };
1522
+ updateFeedbackDraft(row.recordId, { eventId, kind, summary, outcomeStatus, phase: 'submitting', status: '' });
1523
+ render();
1524
+ try {
1525
+ const response = await request(`${state.activeBasePath}/feedback`, {
1526
+ method: 'POST',
1527
+ headers: {
1528
+ 'content-type': 'application/json',
1529
+ ...(token ? { 'x-brainbase-review-token': token } : {}),
1530
+ },
1531
+ body: JSON.stringify({ record_id: row.recordId, event_id: eventId, kind, content }),
1532
+ });
1533
+ if (!response || response.ok !== true) {
1534
+ const statusCode = response?.status ?? 0;
1535
+ const unavailable = [401, 403, 404, 405, 501, 503].includes(statusCode);
1536
+ updateFeedbackDraft(row.recordId, {
1537
+ phase: unavailable ? 'unavailable' : 'unconfirmed',
1538
+ status: unavailable ? `正本へ接続できません(HTTP ${statusCode})。` : `受付応答はHTTP ${statusCode}でした。`,
1539
+ });
1540
+ render();
1541
+ return false;
1542
+ }
1543
+ let responsePayload = null;
1544
+ if (typeof response.json === 'function') {
1545
+ try { responsePayload = await response.json(); } catch { responsePayload = null; }
1546
+ }
1547
+ updateFeedbackDraft(row.recordId, { phase: 'readback', status: '' });
1548
+ render();
1549
+ const detail = await loadDetail(row);
1550
+ const detailReadback = feedbackReadbackMatches(detail, expected);
1551
+ const responseReadback = responseFeedbackReadback(responsePayload, expected);
1552
+ if (detailReadback || responseReadback) {
1553
+ updateFeedbackDraft(row.recordId, { phase: 'saved', status: '' });
1554
+ } else if (state.detailPhase === 'error') {
1555
+ updateFeedbackDraft(row.recordId, { phase: 'unavailable', status: '正本を読み戻せませんでした。' });
1556
+ } else {
1557
+ updateFeedbackDraft(row.recordId, { phase: 'unconfirmed', status: '同じevent_idの正本記録を確認できませんでした。' });
1558
+ }
1559
+ render();
1560
+ return detailReadback || responseReadback;
1561
+ } catch (error) {
1562
+ updateFeedbackDraft(row.recordId, {
1563
+ phase: 'unavailable',
1564
+ status: `正本へ接続できません。${error instanceof Error ? error.message : String(error)}`,
1565
+ });
1566
+ render();
1567
+ return false;
1568
+ }
1569
+ }
1570
+
1571
+ function historyPath(path, cursor = null) {
1572
+ const params = new URLSearchParams();
1573
+ params.set('period', state.period);
1574
+ if (state.project) params.set('project', state.project);
1575
+ if (state.entrypoint) params.set('entrypoint', state.entrypoint);
1576
+ params.set('limit', String(state.limit));
1577
+ if (cursor) params.set('cursor', cursor);
1578
+ return `${path}/home?${params.toString()}`;
1579
+ }
1580
+
1581
+ function normalMode() {
1582
+ return state.home?.mode === 'normal' || state.aggregate?.mode === 'normal';
1583
+ }
1584
+
1585
+ function handleNormalFilterChange(changes) {
1586
+ if (!normalMode()) return;
1587
+ if (Object.prototype.hasOwnProperty.call(changes, 'project')) state.project = changes.project;
1588
+ if (Object.prototype.hasOwnProperty.call(changes, 'entrypoint')) state.entrypoint = changes.entrypoint;
1589
+ state.cursor = null;
1590
+ state.selectedKey = null;
1591
+ state.details = new Map();
1592
+ void load({ append: false });
1593
+ }
1594
+
1595
+ async function loadDetail(row) {
1596
+ if (!row || row.mode !== 'normal' || !request || state.disposed) return null;
1597
+ const generation = ++state.detailGeneration;
1598
+ state.detailPhase = 'loading';
1599
+ state.detailError = null;
1600
+ render();
1601
+ try {
1602
+ const response = await request(`${state.activeBasePath}/records/${encodeURIComponent(row.recordId)}`, {
1603
+ method: 'GET',
1604
+ headers: token ? { 'x-brainbase-review-token': token } : undefined,
1605
+ });
1606
+ if (generation !== state.detailGeneration || state.disposed) return null;
1607
+ if (!response || response.ok !== true) throw new Error(`http_${response?.status ?? 0}`);
1608
+ const payload = await response.json();
1609
+ if (generation !== state.detailGeneration || state.disposed) return null;
1610
+ let rawRecord = isRecord(payload?.record) ? { ...payload.record } : isRecord(payload) && text(payload.record_id) ? payload : null;
1611
+ if (rawRecord && isRecord(payload)) {
1612
+ for (const key of ['feedback_events', 'feedback', 'events', 'appended_events', 'event', 'feedback_event']) {
1613
+ if (rawRecord[key] === undefined && payload[key] !== undefined) rawRecord[key] = payload[key];
1614
+ }
1615
+ }
1616
+ const detail = normalizeNormalRecord(rawRecord);
1617
+ if (!detail) throw new Error('record_response_invalid');
1618
+ state.details.set(row.recordId, detail);
1619
+ state.detailPhase = 'ready';
1620
+ render();
1621
+ return detail;
1622
+ } catch (error) {
1623
+ if (generation !== state.detailGeneration || state.disposed) return null;
1624
+ state.detailPhase = 'error';
1625
+ state.detailError = error instanceof Error ? error.message : String(error);
1626
+ render();
1627
+ return null;
1628
+ }
1629
+ }
1630
+
653
1631
  function render() {
654
1632
  if (state.disposed) return;
655
1633
  const previousSearchInput = findFirst(root, (node) => node.tagName === 'INPUT');
@@ -672,19 +1650,24 @@ export function createJudgmentHistoryUI({
672
1650
  }
673
1651
  const notice = statusNotice(doc, state.phase, state.error, state.home, () => void load());
674
1652
  if (notice) wrapper.append(notice);
675
- if (state.phase === 'loading' || state.phase === 'error' || state.home?.status === 'unavailable' || state.home?.status === 'invalid') {
1653
+ if (state.phase === 'loading' || state.phase === 'error' || state.home?.status === 'unavailable' || state.home?.status === 'error' || state.home?.status === 'invalid') {
676
1654
  wrapper.append(workspaceDetailEmpty(doc, { mark: '…', title: state.phase === 'loading' ? '判断履歴を読み込んでいます' : '判断履歴を確認できません', text: '再試行すると最新の状態を確認します。' }));
677
1655
  root.replaceChildren(wrapper);
678
- renderRail(doc, rail, null);
1656
+ renderRail(doc, rail, null, null);
679
1657
  return;
680
1658
  }
681
1659
  const controls = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-controls' });
682
1660
  controls.append(periodButtons(doc, state.period, (period) => {
683
1661
  state.period = period;
684
1662
  state.selectedKey = null;
685
- state.aggregate = aggregateJudgmentHistory(state.home, { now: readNow(), period: state.period, query: state.query });
686
- render();
1663
+ state.cursor = null;
1664
+ if (normalMode()) void load({ append: false });
1665
+ else {
1666
+ state.aggregate = aggregateJudgmentHistory(state.home, { now: readNow(), period: state.period, query: state.query });
1667
+ render();
1668
+ }
687
1669
  }));
1670
+ if (normalMode()) controls.append(normalFilterControls(doc, state.home, state, handleNormalFilterChange));
688
1671
  controls.append(createSearch(doc, state.query, (query) => {
689
1672
  state.query = String(query);
690
1673
  state.focusSearch = true;
@@ -699,20 +1682,26 @@ export function createJudgmentHistoryUI({
699
1682
  attrs: { 'aria-label': '判断履歴を再読み込み', 'data-action': 'reload' },
700
1683
  }));
701
1684
  wrapper.append(controls);
1685
+ const normalSourceSummary = normalSourcesSummary(state.home);
702
1686
  wrapper.append(makeWorkspaceElement(doc, 'p', {
703
1687
  className: 'bb-jh-reference-note',
704
- text: '判断に残った参照だけを表示しています。実行結果の証拠は参照件数に含めません。',
1688
+ text: normalMode()
1689
+ ? `保存された判断の理由、当時の参照、案、実行と結果を表示しています。参照の本文は現在の権限で確認できる場合だけ開きます。${normalSourceSummary ? ` 取得元: ${normalSourceSummary}` : ''}`
1690
+ : '判断に残った参照だけを表示しています。実行結果の証拠は参照件数に含めません。',
705
1691
  }));
706
- if (state.home?.status === 'partial') wrapper.append(statusNotice(doc, 'ready', null, state.home, () => void load()));
707
1692
  const stats = aggregate.stats ?? {};
708
1693
  const metricCount = (value, suffix) => value === null || value === undefined ? '未確認' : `${value}${suffix}`;
709
- wrapper.append(workspaceMetrics(doc, [
1694
+ wrapper.append(workspaceMetrics(doc, normalMode() ? [
1695
+ { label: '判断件数', value: metricCount(stats.judgments, '件'), note: 'この期間・絞り込みの履歴' },
1696
+ { label: '使った参照', value: metricCount(stats.references, '件'), note: '当時の記録にある一意の参照' },
1697
+ { label: '確認済みの結果', value: metricCount(stats.confirmedOutcomes, '件'), note: '成果が確認済みと記録された判断' },
1698
+ ] : [
710
1699
  { label: 'Brainbaseが代わりに判断した件数', value: metricCount(stats.judgments, '件'), note: 'この期間の履歴' },
711
1700
  { label: 'そこで使った参照', value: metricCount(stats.references, '件'), note: '代理判断に含まれる一意の参照' },
712
1701
  { label: '判断回数にすると', value: metricCount(stats.equivalent, '回分相当'), note: '代理判断1件を1回分として表示' },
713
1702
  ], { ariaLabel: '判断履歴の集計' }));
714
1703
  const emptyText = state.home?.status === 'partial'
715
- ? 'この期間に取得できた判断履歴はありません。'
1704
+ ? 'この期間に取得できた判断履歴はありません。未接続・一部取得のため0件とは確認できません。'
716
1705
  : 'この期間の判断履歴はありません。';
717
1706
  wrapper.append(workspaceLedger(doc, {
718
1707
  className: 'bb-jh-ledger',
@@ -721,10 +1710,21 @@ export function createJudgmentHistoryUI({
721
1710
  rows: makeRows(doc, aggregate, state.selectedKey, (key) => {
722
1711
  state.lastFocusedKey = key;
723
1712
  state.selectedKey = key;
1713
+ state.detailPhase = 'idle';
1714
+ state.detailError = null;
724
1715
  render();
1716
+ const selectedRow = aggregate.rows.find((row) => row.key === key);
1717
+ if (selectedRow?.mode === 'normal') void loadDetail(selectedRow);
725
1718
  }),
726
1719
  empty: emptyText,
727
1720
  }));
1721
+ const pagination = paginationControls(doc, aggregate, () => {
1722
+ if (aggregate.pagination?.nextCursor) {
1723
+ state.cursor = aggregate.pagination.nextCursor;
1724
+ void load({ cursor: state.cursor, append: true });
1725
+ }
1726
+ });
1727
+ if (pagination) wrapper.append(pagination);
728
1728
  const nextSearchInput = findFirst(wrapper, (node) => node.tagName === 'INPUT');
729
1729
  if (previousSearchInput && nextSearchInput) {
730
1730
  previousSearchInput.value = state.query;
@@ -740,7 +1740,23 @@ export function createJudgmentHistoryUI({
740
1740
  state.focusSearch = false;
741
1741
  }
742
1742
  const selected = aggregate.rows.find((row) => row.key === state.selectedKey) ?? null;
743
- renderRail(doc, rail, selected, selected ? closeSelected : null);
1743
+ const selectedDetail = selected ? state.details.get(selected.recordId) ?? null : null;
1744
+ const railOptions = selected?.mode === 'normal'
1745
+ ? {
1746
+ detailReady: state.detailPhase === 'ready' && Boolean(selectedDetail),
1747
+ detailPhase: state.detailPhase,
1748
+ draft: feedbackDraft(selected.recordId),
1749
+ onKindChange: (kind) => {
1750
+ updateFeedbackDraft(selected.recordId, { kind: feedbackKind(kind) ?? 'feedback' });
1751
+ render();
1752
+ },
1753
+ onSummaryChange: (summary) => updateFeedbackDraft(selected.recordId, { summary: String(summary ?? '') }),
1754
+ onOutcomeStatusChange: (outcomeStatus) => updateFeedbackDraft(selected.recordId, { outcomeStatus: String(outcomeStatus ?? '') }),
1755
+ onSubmitFeedback: (controls) => void submitFeedback(selected, controls),
1756
+ onCopyReference: (payload) => void copyReference(payload),
1757
+ }
1758
+ : {};
1759
+ renderRail(doc, rail, selected, selectedDetail, selected ? closeSelected : null, railOptions);
744
1760
  if (state.lastFocusedKey) {
745
1761
  const row = findFirst(root, (node) => attributeValue(node, 'data-key') === state.lastFocusedKey);
746
1762
  row?.focus?.();
@@ -748,13 +1764,15 @@ export function createJudgmentHistoryUI({
748
1764
  }
749
1765
  }
750
1766
 
751
- async function load() {
1767
+ async function load({ cursor = null, append = false } = {}) {
752
1768
  if (state.disposed) return null;
753
1769
  const generation = ++state.generation;
754
1770
  state.phase = 'loading';
755
1771
  state.error = null;
756
- state.home = null;
757
- state.aggregate = null;
1772
+ if (!append) {
1773
+ state.home = null;
1774
+ state.aggregate = null;
1775
+ }
758
1776
  render();
759
1777
  if (!request) {
760
1778
  if (generation !== state.generation || state.disposed) return null;
@@ -764,19 +1782,45 @@ export function createJudgmentHistoryUI({
764
1782
  return null;
765
1783
  }
766
1784
  try {
767
- const response = await request(`${normalizedBasePath}/home`, {
1785
+ let activePath = normalizedBasePath;
1786
+ let response = await request(historyPath(normalizedBasePath, cursor), {
768
1787
  method: 'GET',
769
1788
  headers: token ? { 'x-brainbase-review-token': token } : undefined,
770
1789
  });
771
1790
  if (generation !== state.generation || state.disposed) return null;
1791
+ if (response?.status === 404 && legacyBasePath && !append && !state.project && !state.entrypoint && !state.cursor) {
1792
+ activePath = legacyBasePath;
1793
+ response = await request(`${legacyBasePath}/home`, {
1794
+ method: 'GET',
1795
+ headers: token ? { 'x-brainbase-review-token': token } : undefined,
1796
+ });
1797
+ }
772
1798
  if (!response || response.ok !== true) throw new Error(`http_${response?.status ?? 0}`);
773
1799
  const payload = await response.json();
774
1800
  if (generation !== state.generation || state.disposed) return null;
775
- state.home = normalizeJudgmentHistoryHome(payload);
1801
+ const normalized = normalizeJudgmentHistoryHome(payload);
1802
+ state.activeBasePath = activePath;
1803
+ if (append && state.home?.mode === 'normal' && normalized.mode === 'normal') {
1804
+ const records = [...state.home.records];
1805
+ const knownRecordIds = new Set(records.map((record) => record.recordId));
1806
+ for (const record of normalized.records) {
1807
+ if (knownRecordIds.has(record.recordId)) continue;
1808
+ knownRecordIds.add(record.recordId);
1809
+ records.push(record);
1810
+ }
1811
+ state.home = { ...normalized, records, coverage: normalized.coverage ?? state.home.coverage };
1812
+ } else {
1813
+ state.home = normalized;
1814
+ }
1815
+ if (state.home.mode === 'normal') {
1816
+ state.project = state.project ?? state.home.filters?.project ?? null;
1817
+ state.entrypoint = state.entrypoint ?? state.home.filters?.entrypoint ?? null;
1818
+ state.cursor = state.home.pagination?.nextCursor ?? null;
1819
+ }
776
1820
  state.phase = 'ready';
777
1821
  state.aggregate = aggregateJudgmentHistory(state.home, { now: readNow(), period: state.period, query: state.query });
778
1822
  if (state.pendingDecisionId) {
779
- const pending = state.aggregate.rows.find((row) => row.decisionAttemptId === state.pendingDecisionId);
1823
+ const pending = state.aggregate.rows.find((row) => row.decisionAttemptId === state.pendingDecisionId || row.recordId === state.pendingDecisionId);
780
1824
  state.pendingDecisionId = null;
781
1825
  if (pending) state.selectedKey = pending.key;
782
1826
  }
@@ -798,11 +1842,12 @@ export function createJudgmentHistoryUI({
798
1842
  state.pendingDecisionId = id;
799
1843
  return true;
800
1844
  }
801
- const row = state.aggregate?.rows?.find((candidate) => candidate.decisionAttemptId === id);
1845
+ const row = state.aggregate?.rows?.find((candidate) => candidate.decisionAttemptId === id || candidate.recordId === id);
802
1846
  if (!row) return false;
803
1847
  state.selectedKey = row.key;
804
1848
  state.lastFocusedKey = row.key;
805
1849
  render();
1850
+ if (row.mode === 'normal') void loadDetail(row);
806
1851
  return true;
807
1852
  }
808
1853