@unson/brainbase-mcp 0.23.2 → 0.23.4

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);
@@ -459,10 +849,27 @@ function appendReferenceList(doc, references) {
459
849
  return list;
460
850
  }
461
851
 
852
+ function evidenceIdentifier(entry) {
853
+ const direct = text(entry);
854
+ if (direct) return direct;
855
+ if (!isRecord(entry)) return '';
856
+ const label = text(entry.label);
857
+ const kind = text(entry.kind);
858
+ const reference = text(entry.ref);
859
+ const identity = [kind, reference].filter(Boolean).join(':');
860
+ if (label && identity) return `${label}(${identity})`;
861
+ return label || identity;
862
+ }
863
+
864
+ function evidenceText(entry) {
865
+ const status = isRecord(entry) && entry.status === 'verified' ? '確認済み' : '未確認';
866
+ return `${evidenceIdentifier(entry) || '証拠の識別情報は未確認'}(${status})`;
867
+ }
868
+
462
869
  function appendSupplement(doc, proof) {
463
870
  const execution = isRecord(proof.execution) ? proof.execution : {};
464
871
  const outcome = isRecord(proof.outcome) ? proof.outcome : {};
465
- const evidence = Array.isArray(outcome.evidence_refs) ? outcome.evidence_refs.filter(Boolean).join('、') : '';
872
+ const evidence = Array.isArray(outcome.evidence_refs) ? outcome.evidence_refs.filter(Boolean).map(evidenceText).join('、') : '';
466
873
  return workspaceDefinition(doc, [
467
874
  ['実行', [text(execution.status) || '未記録', text(execution.summary)]],
468
875
  ['結果', [text(outcome.status) || '未記録', text(outcome.summary), evidence ? `証拠: ${evidence}` : '']],
@@ -470,6 +877,49 @@ function appendSupplement(doc, proof) {
470
877
  ]);
471
878
  }
472
879
 
880
+ function appendNormalAlternatives(doc, alternatives) {
881
+ if (alternatives === null || alternatives === undefined) {
882
+ return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '案は記録されていません。' });
883
+ }
884
+ if (!alternatives.length) return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '保存された案はありません。' });
885
+ const list = makeWorkspaceElement(doc, 'ul', { className: 'bb-jh-alternative-list' });
886
+ for (const alternative of alternatives) {
887
+ const item = makeWorkspaceElement(doc, 'li');
888
+ item.append(makeWorkspaceElement(doc, 'strong', { text: alternative.label || '案の名称は未記録' }));
889
+ if (alternative.evaluation) item.append(makeWorkspaceElement(doc, 'span', { text: `評価: ${alternative.evaluation}` }));
890
+ if (alternative.adoption) item.append(makeWorkspaceElement(doc, 'span', { text: `採用状態: ${alternative.adoption}` }));
891
+ else if (alternative.adopted === true) item.append(makeWorkspaceElement(doc, 'span', { text: '採用状態: 採用' }));
892
+ else if (alternative.adopted === false) item.append(makeWorkspaceElement(doc, 'span', { text: '採用状態: 不採用' }));
893
+ else if (Object.prototype.hasOwnProperty.call(alternative, 'adopted')) item.append(makeWorkspaceElement(doc, 'span', { text: '採用状態: 記録なし' }));
894
+ list.append(item);
895
+ }
896
+ return list;
897
+ }
898
+
899
+ function normalExecutionLabel(execution) {
900
+ const status = text(execution?.status).toLowerCase();
901
+ const labels = { pending: '実行待ち', completed: '実行済み', failed: '実行失敗', held: '保留', cancelled: '取消', unknown: '実行状態未確認' };
902
+ return labels[status] ?? '実行状態未確認';
903
+ }
904
+
905
+ function normalOutcomeLabel(execution) {
906
+ const status = text(execution?.outcome_status).toLowerCase();
907
+ const labels = { confirmed: '結果確認済み', unconfirmed: '結果未確認', unknown: '結果未確認' };
908
+ return labels[status] ?? '結果未確認';
909
+ }
910
+
911
+ function appendNormalExecution(doc, execution) {
912
+ return workspaceDefinition(doc, [
913
+ ['実行', [normalExecutionLabel(execution), text(execution?.result_summary)]],
914
+ ['結果', normalOutcomeLabel(execution)],
915
+ ]);
916
+ }
917
+
918
+ function appendNormalMissingFields(doc, missingFields) {
919
+ if (!Array.isArray(missingFields) || missingFields.length === 0) return null;
920
+ return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-missing-fields', text: `未記録: ${missingFields.join('、')}` });
921
+ }
922
+
473
923
  function appendTextParts(doc, values) {
474
924
  const wrap = makeWorkspaceElement(doc, 'div');
475
925
  for (const value of values) {
@@ -478,7 +928,209 @@ function appendTextParts(doc, values) {
478
928
  return wrap;
479
929
  }
480
930
 
481
- function renderRail(doc, rail, row, onClose) {
931
+ function normalFeedbackContentSummary(content) {
932
+ if (!isRecord(content)) return '内容は未記録';
933
+ return text(content.summary ?? content.result_summary ?? content.message ?? content.reason)
934
+ || canonicalJson(content)
935
+ || '内容は未記録';
936
+ }
937
+
938
+ function normalReferencePayload(record, event, applicabilityReason = '', eventRef = null) {
939
+ const payload = {
940
+ ref: `judgment-history:${record.recordId}`,
941
+ record_id: record.recordId,
942
+ why: text(applicabilityReason) || null,
943
+ };
944
+ if (event) {
945
+ payload.event_ref = eventRef;
946
+ payload.event_id = event.eventId;
947
+ payload.kind = event.kind;
948
+ }
949
+ return payload;
950
+ }
951
+
952
+ function normalReferencePayloadText(record, event, applicabilityReason = '', eventRef = null) {
953
+ return JSON.stringify(normalReferencePayload(record, event, applicabilityReason, eventRef), null, 2);
954
+ }
955
+
956
+ function appendNormalReferenceExport(doc, record, event, { onCopyReference } = {}) {
957
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-reference-export' });
958
+ const refLabel = event
959
+ ? `${record.recordId} / ${event.eventId}`
960
+ : `${record.recordId} / イベントIDは未記録`;
961
+ wrap.append(makeWorkspaceElement(doc, 'code', { text: refLabel }));
962
+ if (event) wrap.append(makeWorkspaceElement(doc, 'p', { text: `種別: ${FEEDBACK_KIND_LABELS[event.kind] ?? event.kind}・${normalFeedbackContentSummary(event.content)}` }));
963
+ const reason = makeWorkspaceElement(doc, 'textarea', {
964
+ className: 'bb-jh-applicability-reason',
965
+ attrs: {
966
+ rows: 2,
967
+ 'aria-label': `適用理由${event ? `(${event.eventId})` : ''}`,
968
+ placeholder: '次の判断で使う理由を記入',
969
+ },
970
+ });
971
+ reason.value = '';
972
+ wrap.append(makeWorkspaceElement(doc, 'label', { text: '適用理由' }), reason);
973
+ const preview = makeWorkspaceElement(doc, 'pre', {
974
+ className: 'bb-jh-reference-payload',
975
+ text: normalReferencePayloadText(record, event),
976
+ });
977
+ const status = makeWorkspaceElement(doc, 'span', { className: 'bb-jh-copy-status', text: 'この情報を次の判断へ渡せます。' });
978
+ wrap.append(preview, workspaceButton(doc, {
979
+ text: '参照情報をコピー',
980
+ variant: 'quiet',
981
+ attrs: { 'data-action': 'copy-judgment-reference' },
982
+ onClick: () => {
983
+ if (typeof onCopyReference === 'function') onCopyReference({ record, event, reason, preview, status });
984
+ else status.textContent = '参照情報を選択してコピーできます。';
985
+ },
986
+ }), status);
987
+ return wrap;
988
+ }
989
+
990
+ function appendNormalFeedbackEvents(doc, record, { onCopyReference } = {}) {
991
+ const events = Array.isArray(record.feedbackEvents) ? record.feedbackEvents : [];
992
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-feedback-events' });
993
+ if (!events.length) {
994
+ wrap.append(makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '追記された結果・訂正・フィードバックはありません。' }));
995
+ wrap.append(appendNormalReferenceExport(doc, record, null, { onCopyReference }));
996
+ return wrap;
997
+ }
998
+ for (const event of events) {
999
+ const item = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-feedback-event' });
1000
+ item.append(makeWorkspaceElement(doc, 'strong', { text: FEEDBACK_KIND_LABELS[event.kind] ?? event.kind }));
1001
+ item.append(makeWorkspaceElement(doc, 'code', { text: `record_id=${event.recordId} event_id=${event.eventId}` }));
1002
+ item.append(makeWorkspaceElement(doc, 'p', { text: normalFeedbackContentSummary(event.content) }));
1003
+ item.append(appendNormalReferenceExport(doc, record, event, { onCopyReference }));
1004
+ wrap.append(item);
1005
+ }
1006
+ return wrap;
1007
+ }
1008
+
1009
+ function feedbackStatusText(draft) {
1010
+ if (!draft) return '';
1011
+ if (draft.phase === 'submitting') return '送信中です。保存状態はまだ未確認です。';
1012
+ if (draft.phase === 'readback') return '正本を読み戻して保存状態を確認しています。';
1013
+ if (draft.phase === 'saved') return '保存済み(正本で確認しました)。';
1014
+ if (draft.phase === 'unavailable') return `利用できません。${draft.status || '正本に接続できないため、保存状態を確認できません。'}`;
1015
+ if (draft.phase === 'unconfirmed') return `未確認。${draft.status || 'POST後の正本読戻しで一致を確認できませんでした。'}`;
1016
+ if (draft.phase === 'error') return `送信できません。保存状態は未確認です。${draft.status ? ` ${draft.status}` : ''}`;
1017
+ return draft.status || '送信前です。';
1018
+ }
1019
+
1020
+ function appendNormalFeedbackComposer(doc, record, {
1021
+ detailReady = false,
1022
+ detailPhase = 'idle',
1023
+ draft,
1024
+ onKindChange,
1025
+ onSummaryChange,
1026
+ onOutcomeStatusChange,
1027
+ onSubmitFeedback,
1028
+ } = {}) {
1029
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-feedback-composer' });
1030
+ if (!detailReady) {
1031
+ const message = detailPhase === 'loading'
1032
+ ? '正本の詳細を確認しています。'
1033
+ : '正本の詳細を確認できないため、追記は利用できません。';
1034
+ wrap.append(makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: message }));
1035
+ return wrap;
1036
+ }
1037
+ const kindLabel = makeWorkspaceElement(doc, 'label', { text: '追記の種別' });
1038
+ const kind = makeWorkspaceElement(doc, 'select', { attrs: { 'aria-label': '追記の種別' } });
1039
+ for (const value of FEEDBACK_KINDS) {
1040
+ kind.append(makeWorkspaceElement(doc, 'option', {
1041
+ text: FEEDBACK_KIND_LABELS[value],
1042
+ attrs: { value },
1043
+ }));
1044
+ }
1045
+ kind.value = draft?.kind ?? 'feedback';
1046
+ kind.addEventListener('change', (event) => onKindChange?.(event?.target?.value ?? kind.value));
1047
+ const summaryLabel = makeWorkspaceElement(doc, 'label', { text: '内容(保存公開フィールド)' });
1048
+ const summary = makeWorkspaceElement(doc, 'textarea', {
1049
+ className: 'bb-jh-feedback-content',
1050
+ attrs: { rows: 3, 'aria-label': '追記の内容', placeholder: '結果・訂正・フィードバックを記入' },
1051
+ });
1052
+ summary.value = draft?.summary ?? '';
1053
+ summary.addEventListener('input', (event) => onSummaryChange?.(event?.target?.value ?? summary.value));
1054
+ const outcomeLabel = makeWorkspaceElement(doc, 'label', { text: '結果の確認状態(明示する場合)' });
1055
+ const outcomeStatus = makeWorkspaceElement(doc, 'select', { attrs: { 'aria-label': '結果の確認状態' } });
1056
+ outcomeStatus.append(makeWorkspaceElement(doc, 'option', { text: '記録しない', attrs: { value: '' } }));
1057
+ for (const value of ['unknown', 'unconfirmed', 'confirmed']) {
1058
+ const label = { unknown: '未確認', unconfirmed: '結果未確認', confirmed: '結果確認済み' }[value];
1059
+ outcomeStatus.append(makeWorkspaceElement(doc, 'option', { text: label, attrs: { value } }));
1060
+ }
1061
+ outcomeStatus.value = draft?.outcomeStatus ?? '';
1062
+ outcomeStatus.addEventListener('change', (event) => onOutcomeStatusChange?.(event?.target?.value ?? outcomeStatus.value));
1063
+ const eventId = makeWorkspaceElement(doc, 'code', { text: `event_id=${draft?.eventId ?? '未発行'}` });
1064
+ const phase = draft?.phase ?? 'idle';
1065
+ const busy = phase === 'submitting' || phase === 'readback';
1066
+ const button = workspaceButton(doc, {
1067
+ text: phase === 'saved' ? '保存済み' : '結果・訂正・フィードバックを追記',
1068
+ variant: 'primary',
1069
+ disabled: busy || phase === 'saved',
1070
+ attrs: { 'data-action': 'submit-judgment-feedback' },
1071
+ onClick: () => onSubmitFeedback?.({ kind, summary, outcomeStatus, eventId: draft?.eventId }),
1072
+ });
1073
+ const status = makeWorkspaceElement(doc, 'p', {
1074
+ className: `bb-jh-feedback-status is-${phase}`,
1075
+ text: feedbackStatusText(draft),
1076
+ attrs: { role: phase === 'error' || phase === 'unavailable' ? 'alert' : 'status' },
1077
+ });
1078
+ wrap.append(kindLabel, kind, summaryLabel, summary, outcomeLabel, outcomeStatus, eventId, button, status);
1079
+ return wrap;
1080
+ }
1081
+
1082
+ function renderNormalRail(doc, rail, row, detail, onClose, options = {}) {
1083
+ if (!rail) return;
1084
+ rail.replaceChildren();
1085
+ const record = detail ?? row;
1086
+ rail.append(workspaceRailHead(doc, {
1087
+ kicker: `${NORMAL_ENTRYPOINT_LABELS[record.entrypoint] ?? '入口不明'}の判断`,
1088
+ title: text(record.judgment?.summary) || '判断内容は未記録',
1089
+ sub: formatDate(record.recordedAt),
1090
+ }));
1091
+ if (typeof onClose === 'function') {
1092
+ rail.append(workspaceButton(doc, {
1093
+ text: '詳細を閉じる',
1094
+ variant: 'quiet',
1095
+ onClick: onClose,
1096
+ attrs: { 'aria-label': '判断の詳細を閉じる' },
1097
+ }));
1098
+ }
1099
+ rail.append(workspaceRailBlock(doc, {
1100
+ title: '使った参照(当時の記録)',
1101
+ content: appendReferenceList(doc, record.selectedReferences),
1102
+ }));
1103
+ rail.append(workspaceRailBlock(doc, {
1104
+ title: '判断の理由',
1105
+ content: appendTextParts(doc, [text(record.judgment?.reason) || '理由は記録されていません。']),
1106
+ }));
1107
+ rail.append(workspaceRailBlock(doc, {
1108
+ title: '保存された案',
1109
+ content: appendNormalAlternatives(doc, record.alternatives),
1110
+ }));
1111
+ rail.append(workspaceRailBlock(doc, {
1112
+ title: '実行と結果',
1113
+ content: appendNormalExecution(doc, record.execution),
1114
+ }));
1115
+ rail.append(workspaceRailBlock(doc, {
1116
+ title: '結果・訂正・フィードバック',
1117
+ content: appendNormalFeedbackEvents(doc, record, { onCopyReference: options.onCopyReference }),
1118
+ }));
1119
+ rail.append(workspaceRailBlock(doc, {
1120
+ title: '次の判断に渡す参照',
1121
+ content: appendNormalFeedbackComposer(doc, record, options),
1122
+ }));
1123
+ const metadata = workspaceDefinition(doc, [
1124
+ ['記録ID', record.recordId],
1125
+ ['プロジェクト', record.projectCode],
1126
+ ['Turn', record.turnRef],
1127
+ ]);
1128
+ const missing = appendNormalMissingFields(doc, record.missingFields);
1129
+ if (missing) metadata.append(missing);
1130
+ rail.append(workspaceRailBlock(doc, { title: '記録の範囲', content: metadata }));
1131
+ }
1132
+
1133
+ function renderRail(doc, rail, row, detail, onClose, options = {}) {
482
1134
  if (!rail) return;
483
1135
  rail.replaceChildren();
484
1136
  if (!row) {
@@ -489,6 +1141,10 @@ function renderRail(doc, rail, row, onClose) {
489
1141
  }));
490
1142
  return;
491
1143
  }
1144
+ if (row.mode === 'normal') {
1145
+ renderNormalRail(doc, rail, row, detail, onClose, options);
1146
+ return;
1147
+ }
492
1148
  const proof = row.proof;
493
1149
  rail.append(workspaceRailHead(doc, {
494
1150
  kicker: row.delegated ? 'Brainbaseの判断' : '判断履歴',
@@ -531,9 +1187,16 @@ function statusNotice(doc, phase, error, home, onRetry) {
531
1187
  tone: 'danger',
532
1188
  role: 'alert',
533
1189
  });
1190
+ if (home?.status === 'error') return workspaceNotice(doc, {
1191
+ label: '取得に失敗',
1192
+ text: ['判断の記録を確認できません。データが0件とは確認できていません。', workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
1193
+ tone: 'danger',
1194
+ role: 'alert',
1195
+ });
1196
+ const sourceSummary = normalSourcesSummary(home);
534
1197
  if (home?.status === 'unavailable') return workspaceNotice(doc, {
535
1198
  label: '未接続',
536
- text: ['保存された判断に接続できません。データが0件とは確認できていません。', workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
1199
+ text: [`保存された判断に接続できません${home.coverage?.storage ? `(${normalStorageLabel(home.coverage.storage)})` : ''}。データが0件とは確認できていません。`, sourceSummary ? `取得元: ${sourceSummary}` : null, workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
537
1200
  tone: 'warning',
538
1201
  role: 'alert',
539
1202
  });
@@ -545,7 +1208,7 @@ function statusNotice(doc, phase, error, home, onRetry) {
545
1208
  });
546
1209
  if (home?.status === 'partial') return workspaceNotice(doc, {
547
1210
  label: '取得できた範囲',
548
- text: [`読み取れた判断だけを表示しています。${home.coverage?.rejected ? `読み取れなかった記録は${home.coverage.rejected}件あります。` : ''}`, workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
1211
+ 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
1212
  tone: 'warning',
550
1213
  role: 'status',
551
1214
  });
@@ -577,12 +1240,108 @@ function createSearch(doc, value, onInput) {
577
1240
  return label;
578
1241
  }
579
1242
 
1243
+ function normalStorageLabel(storage) {
1244
+ if (storage === 'local') return '端末';
1245
+ if (storage === 'server') return 'サーバー';
1246
+ return storage || '接続先不明';
1247
+ }
1248
+
1249
+ function normalReasonLabel(reason) {
1250
+ const labels = {
1251
+ unconnected: '未接続',
1252
+ mana_not_connected: '未接続(Mana)',
1253
+ company_os_native_source_not_connected: 'Company OSの判断実行履歴が未接続',
1254
+ journal_unavailable: '判断記録がありません',
1255
+ journal_unreadable: '判断記録を読み取れません',
1256
+ };
1257
+ return labels[reason] ?? reason;
1258
+ }
1259
+
1260
+ function normalSourceStatusLabel(status) {
1261
+ const labels = {
1262
+ available: '接続済み',
1263
+ partial: '一部取得',
1264
+ unavailable: '未接続',
1265
+ };
1266
+ return labels[status] ?? '未確認';
1267
+ }
1268
+
1269
+ function normalSourcesSummary(home) {
1270
+ if (home?.mode !== 'normal' || !Array.isArray(home.coverage?.sources) || home.coverage.sources.length === 0) return '';
1271
+ return home.coverage.sources.map((source) => {
1272
+ const entrypoint = NORMAL_ENTRYPOINT_LABELS[normalEntrypoint(source.entrypoint)] ?? '入口不明';
1273
+ const reason = source.reason ? `(${normalReasonLabel(source.reason)})` : '';
1274
+ return `${entrypoint}: ${normalSourceStatusLabel(source.status)}${reason}`;
1275
+ }).join('、');
1276
+ }
1277
+
1278
+ function createFilterSelect(doc, { label, value, options, onChange, className = '' }) {
1279
+ const wrap = makeWorkspaceElement(doc, 'label', { className: `bb-jh-filter${className ? ` ${className}` : ''}` });
1280
+ wrap.append(makeWorkspaceElement(doc, 'span', { text: label }));
1281
+ const select = makeWorkspaceElement(doc, 'select', { attrs: { 'aria-label': label } });
1282
+ const current = value ?? '';
1283
+ const choices = [{ value: '', label: 'すべて' }, ...options];
1284
+ for (const option of choices) {
1285
+ const optionElement = makeWorkspaceElement(doc, 'option', { text: option.label, attrs: { value: option.value, selected: option.value === current ? 'selected' : undefined } });
1286
+ optionElement.value = option.value;
1287
+ select.append(optionElement);
1288
+ }
1289
+ select.value = current;
1290
+ select.addEventListener('change', (event) => onChange(event?.target?.value ?? select.value ?? ''));
1291
+ wrap.append(select);
1292
+ return wrap;
1293
+ }
1294
+
1295
+ function normalFilterControls(doc, home, state, onChange) {
1296
+ const filters = home?.filters ?? {};
1297
+ const projects = [...(filters.projects ?? [])];
1298
+ const knownProjects = (home.records ?? []).map((record) => record.projectCode).filter(Boolean);
1299
+ for (const project of knownProjects) {
1300
+ if (!projects.some((option) => option.value === project)) projects.push({ value: project, label: project });
1301
+ }
1302
+ const entrypoints = [...(filters.entrypoints ?? [])];
1303
+ const knownEntrypoints = (home.records ?? []).map((record) => record.entrypoint).filter(Boolean);
1304
+ for (const entrypoint of [...NORMAL_ENTRYPOINTS, ...knownEntrypoints]) {
1305
+ if (!entrypoints.some((option) => option.value === entrypoint)) {
1306
+ entrypoints.push({ value: entrypoint, label: NORMAL_ENTRYPOINT_LABELS[entrypoint] ?? entrypoint });
1307
+ }
1308
+ }
1309
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-normal-filters', attrs: { 'aria-label': '絞り込み' } });
1310
+ wrap.append(createFilterSelect(doc, {
1311
+ label: 'プロジェクト',
1312
+ value: state.project,
1313
+ options: projects,
1314
+ onChange: (project) => onChange({ project: project || null }),
1315
+ }));
1316
+ wrap.append(createFilterSelect(doc, {
1317
+ label: '入口',
1318
+ value: state.entrypoint,
1319
+ options: entrypoints,
1320
+ onChange: (entrypoint) => onChange({ entrypoint: entrypoint || null }),
1321
+ }));
1322
+ return wrap;
1323
+ }
1324
+
1325
+ function paginationControls(doc, aggregate, onNext) {
1326
+ if (aggregate?.mode !== 'normal') return null;
1327
+ const pagination = aggregate?.pagination;
1328
+ const total = aggregate?.totalCount;
1329
+ if (!pagination?.hasNext && total === null) return null;
1330
+ const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-pagination' });
1331
+ const count = total === null ? '同じ範囲の件数は未確認' : `同じ範囲の件数: ${total}件`;
1332
+ wrap.append(makeWorkspaceElement(doc, 'span', { className: 'bb-jh-pagination-count', text: count }));
1333
+ if (pagination?.hasNext) {
1334
+ wrap.append(workspaceButton(doc, { text: '次のページ', variant: 'quiet', onClick: onNext, attrs: { 'data-action': 'next-page' } }));
1335
+ }
1336
+ return wrap;
1337
+ }
1338
+
580
1339
  function makeRows(doc, aggregate, selectedKey, onSelect) {
581
1340
  return aggregate.rows.map((row) => ({
582
1341
  key: row.key,
583
- label: `${formatDate(row.recordedAt)} ${text(row.proof?.decision?.summary) || '判断履歴'}`,
1342
+ label: `${formatDate(row.recordedAt)} ${row.mode === 'normal' ? text(row.judgment?.summary) || '判断履歴' : text(row.proof?.decision?.summary) || '判断履歴'}`,
584
1343
  selected: row.key === selectedKey,
585
- className: `bb-jh-row${row.delegated ? ' is-delegated' : ' is-returned'}`,
1344
+ className: `bb-jh-row${row.mode === 'normal' ? ' is-normal' : row.delegated ? ' is-delegated' : ' is-returned'}`,
586
1345
  onSelect,
587
1346
  cells: [
588
1347
  makeWorkspaceElement(doc, 'span', { className: 'bb-jh-date', text: formatDate(row.recordedAt) }),
@@ -592,14 +1351,14 @@ function makeRows(doc, aggregate, selectedKey, onSelect) {
592
1351
  }));
593
1352
  }
594
1353
 
595
- /** Create the shared, read-only judgment history controller. */
1354
+ /** Create the shared judgment history controller and canonical feedback adapter. */
596
1355
  export function createJudgmentHistoryUI({
597
1356
  root,
598
1357
  rail = null,
599
1358
  document: explicitDocument,
600
1359
  fetcher,
601
1360
  token,
602
- basePath = '/api/value-proofs',
1361
+ basePath = '/api/judgment-history',
603
1362
  page,
604
1363
  autoLoad = true,
605
1364
  pageHeader = true,
@@ -611,6 +1370,7 @@ export function createJudgmentHistoryUI({
611
1370
  ? fetcher
612
1371
  : typeof globalThis.fetch === 'function' ? (path, init) => globalThis.fetch(path, init) : null;
613
1372
  const normalizedBasePath = String(basePath).replace(/\/+$/u, '');
1373
+ const legacyBasePath = normalizedBasePath === '/api/judgment-history' ? '/api/value-proofs' : null;
614
1374
  const readNow = () => (typeof now === 'function' ? now() : now);
615
1375
  const state = {
616
1376
  phase: autoLoad ? 'loading' : 'idle',
@@ -619,9 +1379,19 @@ export function createJudgmentHistoryUI({
619
1379
  aggregate: null,
620
1380
  period: PERIODS.past30days,
621
1381
  query: '',
1382
+ project: null,
1383
+ entrypoint: null,
1384
+ cursor: null,
1385
+ limit: 50,
622
1386
  selectedKey: null,
1387
+ details: new Map(),
1388
+ detailPhase: 'idle',
1389
+ detailError: null,
1390
+ activeBasePath: normalizedBasePath,
1391
+ feedbackDrafts: new Map(),
623
1392
  disposed: false,
624
1393
  generation: 0,
1394
+ detailGeneration: 0,
625
1395
  focusSearch: false,
626
1396
  lastFocusedKey: null,
627
1397
  pendingDecisionId: null,
@@ -631,6 +1401,8 @@ export function createJudgmentHistoryUI({
631
1401
  if (!state.selectedKey || state.disposed) return false;
632
1402
  state.lastFocusedKey = state.selectedKey;
633
1403
  state.selectedKey = null;
1404
+ state.detailPhase = 'idle';
1405
+ state.detailError = null;
634
1406
  render();
635
1407
  return true;
636
1408
  }
@@ -650,6 +1422,229 @@ export function createJudgmentHistoryUI({
650
1422
  };
651
1423
  }
652
1424
 
1425
+ function newFeedbackEventId() {
1426
+ try {
1427
+ if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
1428
+ } catch {
1429
+ // The fallback below is sufficient for a retry token when Web Crypto is unavailable.
1430
+ }
1431
+ return `judgment-history-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1432
+ }
1433
+
1434
+ function feedbackDraft(recordId) {
1435
+ if (!recordId) return null;
1436
+ let draft = state.feedbackDrafts.get(recordId);
1437
+ if (!draft) {
1438
+ draft = {
1439
+ recordId,
1440
+ eventId: newFeedbackEventId(),
1441
+ kind: 'feedback',
1442
+ summary: '',
1443
+ outcomeStatus: '',
1444
+ phase: 'idle',
1445
+ status: '',
1446
+ };
1447
+ state.feedbackDrafts.set(recordId, draft);
1448
+ }
1449
+ return draft;
1450
+ }
1451
+
1452
+ function updateFeedbackDraft(recordId, changes) {
1453
+ const current = feedbackDraft(recordId);
1454
+ if (!current || !isRecord(changes)) return current;
1455
+ const next = { ...current, ...changes };
1456
+ state.feedbackDrafts.set(recordId, next);
1457
+ return next;
1458
+ }
1459
+
1460
+ function feedbackReadbackMatches(detail, expected) {
1461
+ if (!detail || !expected) return false;
1462
+ const events = Array.isArray(detail.feedbackEvents) ? detail.feedbackEvents : [];
1463
+ return events.some((event) => event.recordId === expected.recordId
1464
+ && event.eventId === expected.eventId
1465
+ && event.kind === expected.kind
1466
+ && canonicalJson(event.content) === canonicalJson(expected.content));
1467
+ }
1468
+
1469
+ function responseFeedbackReadback(payload, expected) {
1470
+ if (!isRecord(payload) || !expected) return false;
1471
+ const candidate = payload.event
1472
+ ?? payload.feedback_event
1473
+ ?? (isRecord(payload.feedback) ? payload.feedback : null)
1474
+ ?? (text(payload.event_id) ? payload : null);
1475
+ const event = normalizeFeedbackEvent(candidate, expected.recordId);
1476
+ if (!event) return false;
1477
+ const storageStatus = text(payload.storage_status ?? payload.storage?.status ?? payload.save_status).toLowerCase();
1478
+ const saved = payload.saved === true || event.saved === true || ['saved', 'stored', 'idempotent', 'confirmed'].includes(storageStatus)
1479
+ || ['saved', 'stored', 'idempotent', 'confirmed'].includes(event.storageStatus)
1480
+ || text(payload.status).toLowerCase() === 'saved';
1481
+ return saved
1482
+ && event.recordId === expected.recordId
1483
+ && event.eventId === expected.eventId
1484
+ && event.kind === expected.kind
1485
+ && canonicalJson(event.content) === canonicalJson(expected.content);
1486
+ }
1487
+
1488
+ async function feedbackReference(record, event) {
1489
+ if (!record || !event) return null;
1490
+ try {
1491
+ const subtle = globalThis.crypto?.subtle;
1492
+ if (!subtle || typeof subtle.digest !== 'function' || typeof TextEncoder !== 'function') return null;
1493
+ const bytes = await subtle.digest('SHA-256', new TextEncoder().encode(event.eventId));
1494
+ const hash = [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
1495
+ return `judgment-history-feedback:${record.recordId}:${hash}`;
1496
+ } catch {
1497
+ return null;
1498
+ }
1499
+ }
1500
+
1501
+ async function copyReference({ record, event, reason, preview, status } = {}) {
1502
+ if (!record || !preview || !status) return false;
1503
+ const applicabilityReason = reason?.value ?? '';
1504
+ preview.textContent = normalReferencePayloadText(record, event, applicabilityReason);
1505
+ const eventRef = await feedbackReference(record, event);
1506
+ const serialized = normalReferencePayloadText(record, event, applicabilityReason, eventRef);
1507
+ preview.textContent = serialized;
1508
+ try {
1509
+ const clipboard = globalThis.navigator?.clipboard;
1510
+ if (!clipboard || typeof clipboard.writeText !== 'function') throw new Error('clipboard_unavailable');
1511
+ await clipboard.writeText(serialized);
1512
+ status.textContent = '参照情報をコピーしました。';
1513
+ return true;
1514
+ } catch {
1515
+ status.textContent = '参照情報を表示しました。選択してコピーできます。';
1516
+ return false;
1517
+ }
1518
+ }
1519
+
1520
+ async function submitFeedback(row, controls) {
1521
+ if (!row || row.mode !== 'normal' || !controls || !request || state.disposed) return false;
1522
+ const current = feedbackDraft(row.recordId);
1523
+ const kind = feedbackKind(controls.kind?.value ?? current?.kind) ?? 'feedback';
1524
+ const summary = text(controls.summary?.value ?? current?.summary);
1525
+ const outcomeStatus = text(controls.outcomeStatus?.value ?? current?.outcomeStatus).toLowerCase();
1526
+ if (!summary) {
1527
+ updateFeedbackDraft(row.recordId, { kind, summary, outcomeStatus: '', phase: 'error', status: '内容を入力してください。' });
1528
+ render();
1529
+ return false;
1530
+ }
1531
+ const acceptedOutcomeStatuses = new Set(['unknown', 'unconfirmed', 'confirmed']);
1532
+ const content = { summary };
1533
+ if (acceptedOutcomeStatuses.has(outcomeStatus)) content.outcome_status = outcomeStatus;
1534
+ const displayedEventId = text(controls.eventId?.textContent);
1535
+ const eventId = displayedEventId.startsWith('event_id=')
1536
+ ? displayedEventId.slice('event_id='.length)
1537
+ : displayedEventId || current?.eventId || newFeedbackEventId();
1538
+ const expected = { recordId: row.recordId, eventId, kind, content };
1539
+ updateFeedbackDraft(row.recordId, { eventId, kind, summary, outcomeStatus, phase: 'submitting', status: '' });
1540
+ render();
1541
+ try {
1542
+ const response = await request(`${state.activeBasePath}/feedback`, {
1543
+ method: 'POST',
1544
+ headers: {
1545
+ 'content-type': 'application/json',
1546
+ ...(token ? { 'x-brainbase-review-token': token } : {}),
1547
+ },
1548
+ body: JSON.stringify({ record_id: row.recordId, event_id: eventId, kind, content }),
1549
+ });
1550
+ if (!response || response.ok !== true) {
1551
+ const statusCode = response?.status ?? 0;
1552
+ const unavailable = [401, 403, 404, 405, 501, 503].includes(statusCode);
1553
+ updateFeedbackDraft(row.recordId, {
1554
+ phase: unavailable ? 'unavailable' : 'unconfirmed',
1555
+ status: unavailable ? `正本へ接続できません(HTTP ${statusCode})。` : `受付応答はHTTP ${statusCode}でした。`,
1556
+ });
1557
+ render();
1558
+ return false;
1559
+ }
1560
+ let responsePayload = null;
1561
+ if (typeof response.json === 'function') {
1562
+ try { responsePayload = await response.json(); } catch { responsePayload = null; }
1563
+ }
1564
+ updateFeedbackDraft(row.recordId, { phase: 'readback', status: '' });
1565
+ render();
1566
+ const detail = await loadDetail(row);
1567
+ const detailReadback = feedbackReadbackMatches(detail, expected);
1568
+ const responseReadback = responseFeedbackReadback(responsePayload, expected);
1569
+ if (detailReadback || responseReadback) {
1570
+ updateFeedbackDraft(row.recordId, { phase: 'saved', status: '' });
1571
+ } else if (state.detailPhase === 'error') {
1572
+ updateFeedbackDraft(row.recordId, { phase: 'unavailable', status: '正本を読み戻せませんでした。' });
1573
+ } else {
1574
+ updateFeedbackDraft(row.recordId, { phase: 'unconfirmed', status: '同じevent_idの正本記録を確認できませんでした。' });
1575
+ }
1576
+ render();
1577
+ return detailReadback || responseReadback;
1578
+ } catch (error) {
1579
+ updateFeedbackDraft(row.recordId, {
1580
+ phase: 'unavailable',
1581
+ status: `正本へ接続できません。${error instanceof Error ? error.message : String(error)}`,
1582
+ });
1583
+ render();
1584
+ return false;
1585
+ }
1586
+ }
1587
+
1588
+ function historyPath(path, cursor = null) {
1589
+ const params = new URLSearchParams();
1590
+ params.set('period', state.period);
1591
+ if (state.project) params.set('project', state.project);
1592
+ if (state.entrypoint) params.set('entrypoint', state.entrypoint);
1593
+ params.set('limit', String(state.limit));
1594
+ if (cursor) params.set('cursor', cursor);
1595
+ return `${path}/home?${params.toString()}`;
1596
+ }
1597
+
1598
+ function normalMode() {
1599
+ return state.home?.mode === 'normal' || state.aggregate?.mode === 'normal';
1600
+ }
1601
+
1602
+ function handleNormalFilterChange(changes) {
1603
+ if (!normalMode()) return;
1604
+ if (Object.prototype.hasOwnProperty.call(changes, 'project')) state.project = changes.project;
1605
+ if (Object.prototype.hasOwnProperty.call(changes, 'entrypoint')) state.entrypoint = changes.entrypoint;
1606
+ state.cursor = null;
1607
+ state.selectedKey = null;
1608
+ state.details = new Map();
1609
+ void load({ append: false });
1610
+ }
1611
+
1612
+ async function loadDetail(row) {
1613
+ if (!row || row.mode !== 'normal' || !request || state.disposed) return null;
1614
+ const generation = ++state.detailGeneration;
1615
+ state.detailPhase = 'loading';
1616
+ state.detailError = null;
1617
+ render();
1618
+ try {
1619
+ const response = await request(`${state.activeBasePath}/records/${encodeURIComponent(row.recordId)}`, {
1620
+ method: 'GET',
1621
+ headers: token ? { 'x-brainbase-review-token': token } : undefined,
1622
+ });
1623
+ if (generation !== state.detailGeneration || state.disposed) return null;
1624
+ if (!response || response.ok !== true) throw new Error(`http_${response?.status ?? 0}`);
1625
+ const payload = await response.json();
1626
+ if (generation !== state.detailGeneration || state.disposed) return null;
1627
+ let rawRecord = isRecord(payload?.record) ? { ...payload.record } : isRecord(payload) && text(payload.record_id) ? payload : null;
1628
+ if (rawRecord && isRecord(payload)) {
1629
+ for (const key of ['feedback_events', 'feedback', 'events', 'appended_events', 'event', 'feedback_event']) {
1630
+ if (rawRecord[key] === undefined && payload[key] !== undefined) rawRecord[key] = payload[key];
1631
+ }
1632
+ }
1633
+ const detail = normalizeNormalRecord(rawRecord);
1634
+ if (!detail) throw new Error('record_response_invalid');
1635
+ state.details.set(row.recordId, detail);
1636
+ state.detailPhase = 'ready';
1637
+ render();
1638
+ return detail;
1639
+ } catch (error) {
1640
+ if (generation !== state.detailGeneration || state.disposed) return null;
1641
+ state.detailPhase = 'error';
1642
+ state.detailError = error instanceof Error ? error.message : String(error);
1643
+ render();
1644
+ return null;
1645
+ }
1646
+ }
1647
+
653
1648
  function render() {
654
1649
  if (state.disposed) return;
655
1650
  const previousSearchInput = findFirst(root, (node) => node.tagName === 'INPUT');
@@ -672,19 +1667,24 @@ export function createJudgmentHistoryUI({
672
1667
  }
673
1668
  const notice = statusNotice(doc, state.phase, state.error, state.home, () => void load());
674
1669
  if (notice) wrapper.append(notice);
675
- if (state.phase === 'loading' || state.phase === 'error' || state.home?.status === 'unavailable' || state.home?.status === 'invalid') {
1670
+ if (state.phase === 'loading' || state.phase === 'error' || state.home?.status === 'unavailable' || state.home?.status === 'error' || state.home?.status === 'invalid') {
676
1671
  wrapper.append(workspaceDetailEmpty(doc, { mark: '…', title: state.phase === 'loading' ? '判断履歴を読み込んでいます' : '判断履歴を確認できません', text: '再試行すると最新の状態を確認します。' }));
677
1672
  root.replaceChildren(wrapper);
678
- renderRail(doc, rail, null);
1673
+ renderRail(doc, rail, null, null);
679
1674
  return;
680
1675
  }
681
1676
  const controls = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-controls' });
682
1677
  controls.append(periodButtons(doc, state.period, (period) => {
683
1678
  state.period = period;
684
1679
  state.selectedKey = null;
685
- state.aggregate = aggregateJudgmentHistory(state.home, { now: readNow(), period: state.period, query: state.query });
686
- render();
1680
+ state.cursor = null;
1681
+ if (normalMode()) void load({ append: false });
1682
+ else {
1683
+ state.aggregate = aggregateJudgmentHistory(state.home, { now: readNow(), period: state.period, query: state.query });
1684
+ render();
1685
+ }
687
1686
  }));
1687
+ if (normalMode()) controls.append(normalFilterControls(doc, state.home, state, handleNormalFilterChange));
688
1688
  controls.append(createSearch(doc, state.query, (query) => {
689
1689
  state.query = String(query);
690
1690
  state.focusSearch = true;
@@ -699,20 +1699,26 @@ export function createJudgmentHistoryUI({
699
1699
  attrs: { 'aria-label': '判断履歴を再読み込み', 'data-action': 'reload' },
700
1700
  }));
701
1701
  wrapper.append(controls);
1702
+ const normalSourceSummary = normalSourcesSummary(state.home);
702
1703
  wrapper.append(makeWorkspaceElement(doc, 'p', {
703
1704
  className: 'bb-jh-reference-note',
704
- text: '判断に残った参照だけを表示しています。実行結果の証拠は参照件数に含めません。',
1705
+ text: normalMode()
1706
+ ? `保存された判断の理由、当時の参照、案、実行と結果を表示しています。参照の本文は現在の権限で確認できる場合だけ開きます。${normalSourceSummary ? ` 取得元: ${normalSourceSummary}` : ''}`
1707
+ : '判断に残った参照だけを表示しています。実行結果の証拠は参照件数に含めません。',
705
1708
  }));
706
- if (state.home?.status === 'partial') wrapper.append(statusNotice(doc, 'ready', null, state.home, () => void load()));
707
1709
  const stats = aggregate.stats ?? {};
708
1710
  const metricCount = (value, suffix) => value === null || value === undefined ? '未確認' : `${value}${suffix}`;
709
- wrapper.append(workspaceMetrics(doc, [
1711
+ wrapper.append(workspaceMetrics(doc, normalMode() ? [
1712
+ { label: '判断件数', value: metricCount(stats.judgments, '件'), note: 'この期間・絞り込みの履歴' },
1713
+ { label: '使った参照', value: metricCount(stats.references, '件'), note: '当時の記録にある一意の参照' },
1714
+ { label: '確認済みの結果', value: metricCount(stats.confirmedOutcomes, '件'), note: '成果が確認済みと記録された判断' },
1715
+ ] : [
710
1716
  { label: 'Brainbaseが代わりに判断した件数', value: metricCount(stats.judgments, '件'), note: 'この期間の履歴' },
711
1717
  { label: 'そこで使った参照', value: metricCount(stats.references, '件'), note: '代理判断に含まれる一意の参照' },
712
1718
  { label: '判断回数にすると', value: metricCount(stats.equivalent, '回分相当'), note: '代理判断1件を1回分として表示' },
713
1719
  ], { ariaLabel: '判断履歴の集計' }));
714
1720
  const emptyText = state.home?.status === 'partial'
715
- ? 'この期間に取得できた判断履歴はありません。'
1721
+ ? 'この期間に取得できた判断履歴はありません。未接続・一部取得のため0件とは確認できません。'
716
1722
  : 'この期間の判断履歴はありません。';
717
1723
  wrapper.append(workspaceLedger(doc, {
718
1724
  className: 'bb-jh-ledger',
@@ -721,10 +1727,21 @@ export function createJudgmentHistoryUI({
721
1727
  rows: makeRows(doc, aggregate, state.selectedKey, (key) => {
722
1728
  state.lastFocusedKey = key;
723
1729
  state.selectedKey = key;
1730
+ state.detailPhase = 'idle';
1731
+ state.detailError = null;
724
1732
  render();
1733
+ const selectedRow = aggregate.rows.find((row) => row.key === key);
1734
+ if (selectedRow?.mode === 'normal') void loadDetail(selectedRow);
725
1735
  }),
726
1736
  empty: emptyText,
727
1737
  }));
1738
+ const pagination = paginationControls(doc, aggregate, () => {
1739
+ if (aggregate.pagination?.nextCursor) {
1740
+ state.cursor = aggregate.pagination.nextCursor;
1741
+ void load({ cursor: state.cursor, append: true });
1742
+ }
1743
+ });
1744
+ if (pagination) wrapper.append(pagination);
728
1745
  const nextSearchInput = findFirst(wrapper, (node) => node.tagName === 'INPUT');
729
1746
  if (previousSearchInput && nextSearchInput) {
730
1747
  previousSearchInput.value = state.query;
@@ -740,7 +1757,23 @@ export function createJudgmentHistoryUI({
740
1757
  state.focusSearch = false;
741
1758
  }
742
1759
  const selected = aggregate.rows.find((row) => row.key === state.selectedKey) ?? null;
743
- renderRail(doc, rail, selected, selected ? closeSelected : null);
1760
+ const selectedDetail = selected ? state.details.get(selected.recordId) ?? null : null;
1761
+ const railOptions = selected?.mode === 'normal'
1762
+ ? {
1763
+ detailReady: state.detailPhase === 'ready' && Boolean(selectedDetail),
1764
+ detailPhase: state.detailPhase,
1765
+ draft: feedbackDraft(selected.recordId),
1766
+ onKindChange: (kind) => {
1767
+ updateFeedbackDraft(selected.recordId, { kind: feedbackKind(kind) ?? 'feedback' });
1768
+ render();
1769
+ },
1770
+ onSummaryChange: (summary) => updateFeedbackDraft(selected.recordId, { summary: String(summary ?? '') }),
1771
+ onOutcomeStatusChange: (outcomeStatus) => updateFeedbackDraft(selected.recordId, { outcomeStatus: String(outcomeStatus ?? '') }),
1772
+ onSubmitFeedback: (controls) => void submitFeedback(selected, controls),
1773
+ onCopyReference: (payload) => void copyReference(payload),
1774
+ }
1775
+ : {};
1776
+ renderRail(doc, rail, selected, selectedDetail, selected ? closeSelected : null, railOptions);
744
1777
  if (state.lastFocusedKey) {
745
1778
  const row = findFirst(root, (node) => attributeValue(node, 'data-key') === state.lastFocusedKey);
746
1779
  row?.focus?.();
@@ -748,13 +1781,15 @@ export function createJudgmentHistoryUI({
748
1781
  }
749
1782
  }
750
1783
 
751
- async function load() {
1784
+ async function load({ cursor = null, append = false } = {}) {
752
1785
  if (state.disposed) return null;
753
1786
  const generation = ++state.generation;
754
1787
  state.phase = 'loading';
755
1788
  state.error = null;
756
- state.home = null;
757
- state.aggregate = null;
1789
+ if (!append) {
1790
+ state.home = null;
1791
+ state.aggregate = null;
1792
+ }
758
1793
  render();
759
1794
  if (!request) {
760
1795
  if (generation !== state.generation || state.disposed) return null;
@@ -764,19 +1799,45 @@ export function createJudgmentHistoryUI({
764
1799
  return null;
765
1800
  }
766
1801
  try {
767
- const response = await request(`${normalizedBasePath}/home`, {
1802
+ let activePath = normalizedBasePath;
1803
+ let response = await request(historyPath(normalizedBasePath, cursor), {
768
1804
  method: 'GET',
769
1805
  headers: token ? { 'x-brainbase-review-token': token } : undefined,
770
1806
  });
771
1807
  if (generation !== state.generation || state.disposed) return null;
1808
+ if (response?.status === 404 && legacyBasePath && !append && !state.project && !state.entrypoint && !state.cursor) {
1809
+ activePath = legacyBasePath;
1810
+ response = await request(`${legacyBasePath}/home`, {
1811
+ method: 'GET',
1812
+ headers: token ? { 'x-brainbase-review-token': token } : undefined,
1813
+ });
1814
+ }
772
1815
  if (!response || response.ok !== true) throw new Error(`http_${response?.status ?? 0}`);
773
1816
  const payload = await response.json();
774
1817
  if (generation !== state.generation || state.disposed) return null;
775
- state.home = normalizeJudgmentHistoryHome(payload);
1818
+ const normalized = normalizeJudgmentHistoryHome(payload);
1819
+ state.activeBasePath = activePath;
1820
+ if (append && state.home?.mode === 'normal' && normalized.mode === 'normal') {
1821
+ const records = [...state.home.records];
1822
+ const knownRecordIds = new Set(records.map((record) => record.recordId));
1823
+ for (const record of normalized.records) {
1824
+ if (knownRecordIds.has(record.recordId)) continue;
1825
+ knownRecordIds.add(record.recordId);
1826
+ records.push(record);
1827
+ }
1828
+ state.home = { ...normalized, records, coverage: normalized.coverage ?? state.home.coverage };
1829
+ } else {
1830
+ state.home = normalized;
1831
+ }
1832
+ if (state.home.mode === 'normal') {
1833
+ state.project = state.project ?? state.home.filters?.project ?? null;
1834
+ state.entrypoint = state.entrypoint ?? state.home.filters?.entrypoint ?? null;
1835
+ state.cursor = state.home.pagination?.nextCursor ?? null;
1836
+ }
776
1837
  state.phase = 'ready';
777
1838
  state.aggregate = aggregateJudgmentHistory(state.home, { now: readNow(), period: state.period, query: state.query });
778
1839
  if (state.pendingDecisionId) {
779
- const pending = state.aggregate.rows.find((row) => row.decisionAttemptId === state.pendingDecisionId);
1840
+ const pending = state.aggregate.rows.find((row) => row.decisionAttemptId === state.pendingDecisionId || row.recordId === state.pendingDecisionId);
780
1841
  state.pendingDecisionId = null;
781
1842
  if (pending) state.selectedKey = pending.key;
782
1843
  }
@@ -798,11 +1859,12 @@ export function createJudgmentHistoryUI({
798
1859
  state.pendingDecisionId = id;
799
1860
  return true;
800
1861
  }
801
- const row = state.aggregate?.rows?.find((candidate) => candidate.decisionAttemptId === id);
1862
+ const row = state.aggregate?.rows?.find((candidate) => candidate.decisionAttemptId === id || candidate.recordId === id);
802
1863
  if (!row) return false;
803
1864
  state.selectedKey = row.key;
804
1865
  state.lastFocusedKey = row.key;
805
1866
  render();
1867
+ if (row.mode === 'normal') void loadDetail(row);
806
1868
  return true;
807
1869
  }
808
1870