mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Review round 1 (three independent reviewers, one batch) on the open-set verdict: - A seat's standing findings are judged once its parse result is final and only when its absence is terminal. An unparseable reply (prose, no findings array) is a non-answer that keeps the seat's earlier finding listed; a seat still awaiting the dispatch barrier, in flight or late-pending is a gap and carries nothing (06 §Plan construction and review: an answer that has not arrived is never a verdict). Before this, two clean seats plus one unparseable objector closed GREEN, and a barrier wave stamped "did not answer" on seats that were merely awaited. - A same-fingerprint re-dispatch (a changed reviewer_effort on an open review) replaces its predecessor in the hot index; the replacing wave now keeps that predecessor's exact artifact reference (previous_wave_artifact, also kept by compact_wave), and free collection resolves the predecessor through it, so the standing seam and the prior-cycle packet see the real predecessor instead of the in-flight wave itself. - A closed predecessor carries nothing (plan_standing_findings now reads closed, as its contract promised): a finding closed by a reasoned advisory reject is earned authority and is not resurrected on a later same-spec wave. - Notes are neutral for the only-awaited stamp: a quorum whose only findings are notes, held open by an awaited seat, is still a mere wait (D4). - Verdict text: the advisory NOTE states the cap fact like the blocking one; a $0 not-sent seat with a carried finding says "not sent; its earlier finding is still listed"; the Reviews card prints the weaker-order line on compact waves too and the carried explanation on every terminal branch; skipped rows carry the seat's effort facts; the trace meta labels the historical critic pair. - Docs: the closure sentence names the CONFIGURED enforcement against the hurry-projected advisory (06, 07); CHECKLISTS keys the changed-spec clause on the spec hash (D4 hunk, protected path: authority D4 / Q-v as before). Tests: two-sided pins for the unparseable objector, the awaiting-then-refused seat through the barrier and collection, the reject-closed predecessor, the changed-spec subcase from an open objection, the note-neutral wait, and the two card branches. Size: plan_review_runtime.py leaves the 1001-1500 band (1507, manifest regenerated); task_results.py 1597; review_presentation.js 1599. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
1599 lines
80 KiB
JavaScript
1599 lines
80 KiB
JavaScript
import { acceptanceGroupWithIncident, acceptanceIncidentFromTaskDetail } from './acceptance_incident_presentation.js';
|
|
export { acceptanceIncidentFromTaskDetail } from './acceptance_incident_presentation.js';
|
|
import { setInertCardPresentation } from './task_phase_chip.js';
|
|
import { escapeHtmlAttr, sinceLocalTime } from './utils.js';
|
|
import { taskSourceDownloadUrl } from './api_client.js';
|
|
import { harnessIdentityMarkup } from './harness_presentation.js';
|
|
import { reconcileReviewMarkup } from './review_dom_patch.js';
|
|
|
|
const escapeHtmlText = escapeHtmlAttr;
|
|
|
|
const SURFACE_ORDER = new Map([
|
|
['skill', 0],
|
|
['plan', 1],
|
|
['task_acceptance', 2],
|
|
]);
|
|
|
|
const ACTIVE_STATES = new Set(['queued', 'running', 'open', 'working', 'pending']);
|
|
const ERROR_STATES = new Set(['failed', 'error', 'blocked', 'blockers', 'timeout']);
|
|
const WARNING_STATES = new Set([
|
|
'warnings', 'warning', 'degraded', 'interrupted', 'cancelled',
|
|
'review_required', 'revise_plan', 'rail_degraded', 'cycles_exhausted',
|
|
]);
|
|
const SUCCESS_STATES = new Set([
|
|
'clean', 'passed', 'pass', 'succeeded', 'success', 'completed', 'closed', 'green',
|
|
]);
|
|
const TERMINAL_STATES = new Set([
|
|
...SUCCESS_STATES, 'terminal',
|
|
...ERROR_STATES, ...WARNING_STATES,
|
|
]);
|
|
// These values belong to the lifecycle of the review job, not to the
|
|
// review's semantic verdict. Keep them terminal for activity purposes while
|
|
// preventing a lifecycle-only row from looking like a review verdict.
|
|
const LIFECYCLE_SUCCESS_STATES = new Set(['succeeded', 'success', 'completed']);
|
|
const LIFECYCLE_TERMINAL_STATES = new Set([
|
|
...LIFECYCLE_SUCCESS_STATES,
|
|
'failed', 'error', 'timeout', 'interrupted', 'cancelled',
|
|
]);
|
|
|
|
const text = (value) => String(value ?? '').trim();
|
|
const finiteCount = (value) => {
|
|
if (value === null || value === undefined || value === '') return null;
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
|
|
};
|
|
// A reviewer answer that has not arrived is a gap, not a verdict: the host
|
|
// types a planned wait as `pending_dispatch` and an exceptional one otherwise.
|
|
const actorAwaiting = (actor) => text(actor?.operation_state) === 'pending_dispatch' && text(actor?.transport_status) !== 'success';
|
|
const actorUnresolved = (actor) => ['in_flight', 'custody_lost'].includes(text(actor?.operation_state));
|
|
// A roster with an unanswered slot reports how far it got, never a bare verdict;
|
|
// a slot that is neither answered nor awaited keeps the warning tone.
|
|
const heldProgress = (lead, roster, answered) => {
|
|
const unavailable = roster.filter((actor) => !actorAwaiting(actor) && !answered(actor)).length;
|
|
return [`${lead} · ${roster.filter(answered).length} of ${roster.length} answered${unavailable ? ` · ${unavailable} unavailable` : ''}`, unavailable ? 'warn' : ''];
|
|
};
|
|
|
|
export function setReviewAnchor(record, enabled, writePhase) {
|
|
if (!record || (record.historicalUnavailable || record.historicalUnconfirmed) || Boolean(record.reviewAnchor) === enabled) return false;
|
|
record.reviewAnchor = enabled;
|
|
setInertCardPresentation(record, enabled);
|
|
if (enabled) {
|
|
record.titleEl.textContent = record.suggestedName || 'Reviews';
|
|
record.inlineTypingEl.style.display = 'none';
|
|
} else {
|
|
writePhase(record, 'working');
|
|
if (!record.suggestedName && !record.lastHumanHeadline) {
|
|
record.titleEl.textContent = 'Working...';
|
|
}
|
|
record.inlineTypingEl.style.display = '';
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function normalizedState(value, fallback = 'unavailable') {
|
|
const state = text(value).toLowerCase();
|
|
if (ACTIVE_STATES.has(state)) {
|
|
if (state === 'pending') return 'queued';
|
|
return state === 'open' || state === 'working' ? 'running' : state;
|
|
}
|
|
if (TERMINAL_STATES.has(state)) return 'terminal';
|
|
if (state === 'unavailable' || state === 'absent') return 'unavailable';
|
|
if (state === 'superseded') return 'superseded';
|
|
return fallback;
|
|
}
|
|
|
|
function statusTone(state, verdict = '') {
|
|
const token = text(verdict || state).toLowerCase();
|
|
if (ERROR_STATES.has(token) || token === 'fail') return 'error';
|
|
if (WARNING_STATES.has(token)) return 'warn';
|
|
if (ACTIVE_STATES.has(token) || state === 'running' || state === 'queued') return 'working';
|
|
if (SUCCESS_STATES.has(token)) return 'done';
|
|
return 'neutral';
|
|
}
|
|
|
|
function lifecycleOnlyTone(lifecycleStatus, state) {
|
|
const token = text(lifecycleStatus).toLowerCase();
|
|
// Successful job completion is deliberately not a semantic PASS. Other
|
|
// lifecycle failures still carry a useful severity fact even though their
|
|
// review verdict is unavailable.
|
|
if (LIFECYCLE_SUCCESS_STATES.has(token)) return 'neutral';
|
|
return statusTone(state, token);
|
|
}
|
|
|
|
function lifecycleFailure(status) {
|
|
const token = text(status).toLowerCase();
|
|
return LIFECYCLE_TERMINAL_STATES.has(token)
|
|
&& !LIFECYCLE_SUCCESS_STATES.has(token);
|
|
}
|
|
|
|
function reviewTone(state, verdict, lifecycleStatus = '') {
|
|
return lifecycleFailure(lifecycleStatus)
|
|
? lifecycleOnlyTone(lifecycleStatus, state)
|
|
: statusTone(state, verdict);
|
|
}
|
|
|
|
function lifecycleMeta(status) {
|
|
const token = text(status).toLowerCase();
|
|
return lifecycleFailure(token) ? `lifecycle ${token}` : '';
|
|
}
|
|
|
|
function hasSemanticVerdict(verdict) {
|
|
const token = text(verdict).toLowerCase();
|
|
return Boolean(token)
|
|
&& !ACTIVE_STATES.has(token)
|
|
&& !LIFECYCLE_TERMINAL_STATES.has(token)
|
|
&& !['terminal', 'unavailable', 'absent'].includes(token);
|
|
}
|
|
|
|
function attemptIdentity(attempt, fallback = '') {
|
|
return text(
|
|
attempt?.id || attempt?.job_id || attempt?.wave_id || attempt?.panel_id
|
|
|| attempt?.request_fingerprint || fallback,
|
|
);
|
|
}
|
|
|
|
function normalizedExecutions(value, legacyExecution = null) {
|
|
const rows = Array.isArray(value) ? value : [];
|
|
if (rows.length) return rows.filter((item) => item && typeof item === 'object');
|
|
const executed = legacyExecution?.executed;
|
|
return executed && typeof executed === 'object' ? [executed] : [];
|
|
}
|
|
|
|
function executionsFromReviewRecord(record) {
|
|
if (!record || typeof record !== 'object') return [];
|
|
const direct = normalizedExecutions(record.executions, record.execution);
|
|
const actorExecutions = (Array.isArray(record.actors) ? record.actors : [])
|
|
.flatMap((actor) => normalizedExecutions(actor?.executions, actor?.execution));
|
|
return [...direct, ...actorExecutions];
|
|
}
|
|
|
|
function uniformAttemptInitiator(attempts, fallback = '') {
|
|
if (!attempts.length) return text(fallback);
|
|
const initiators = attempts.map((attempt) => text(attempt.initiatorTaskId));
|
|
const known = initiators.filter(Boolean);
|
|
if (!known.length) return text(fallback);
|
|
if (known.length !== initiators.length) return '';
|
|
const unique = new Set(initiators);
|
|
return unique.size === 1 ? initiators[0] : '';
|
|
}
|
|
|
|
function normalizeSkillAttempt(attempt, defaults = {}, ordinal = 0) {
|
|
if (!attempt || typeof attempt !== 'object') return null;
|
|
const skill = text(attempt.skill || defaults.skill);
|
|
const jobId = text(attempt.job_id || attempt.jobId);
|
|
const id = attemptIdentity(attempt, [
|
|
text(attempt.content_hash), text(attempt.review_round),
|
|
text(attempt.snapshot_attempt), String(ordinal + 1),
|
|
].join(':'));
|
|
if (!id) return null;
|
|
const lifecycleStatus = text(attempt.lifecycle_status || attempt.job_status);
|
|
const explicitStatus = text(
|
|
attempt.review_status || attempt.review_verdict || attempt.status || defaults.status,
|
|
);
|
|
const lifecycleOnly = attempt.lifecycle_only === true
|
|
|| (!hasSemanticVerdict(explicitStatus)
|
|
&& LIFECYCLE_TERMINAL_STATES.has(lifecycleStatus.toLowerCase()))
|
|
|| (!attempt.review_status && !attempt.review_verdict
|
|
&& LIFECYCLE_TERMINAL_STATES.has(explicitStatus.toLowerCase()));
|
|
const rawStatus = lifecycleOnly ? '' : explicitStatus;
|
|
const state = lifecycleOnly
|
|
? normalizedState(lifecycleStatus || explicitStatus, defaults.state || 'unavailable')
|
|
: normalizedState(rawStatus, defaults.state || 'unavailable');
|
|
return {
|
|
id,
|
|
surface: 'skill',
|
|
state,
|
|
tone: lifecycleOnly
|
|
? lifecycleOnlyTone(lifecycleStatus || explicitStatus, state)
|
|
: reviewTone(state, rawStatus, lifecycleStatus),
|
|
verdict: rawStatus,
|
|
lifecycleStatus,
|
|
timestamp: text(
|
|
attempt.ts || attempt.timestamp || attempt.finished_at
|
|
|| attempt.started_at || attempt.queued_at,
|
|
),
|
|
ordinal,
|
|
label: [
|
|
attempt.review_round != null ? `round ${attempt.review_round}` : '',
|
|
attempt.snapshot_attempt != null ? `attempt ${attempt.snapshot_attempt}` : '',
|
|
].filter(Boolean).join(' · ') || `attempt ${ordinal + 1}`,
|
|
summary: text(attempt.summary || attempt.text || attempt.terminal_reason || attempt.error)
|
|
|| (lifecycleOnly ? 'Review verdict unavailable.' : ''),
|
|
superseded: Boolean(attempt.superseded),
|
|
replayed: Boolean(attempt.replayed || attempt.replay || attempt.replay_of || attempt.replayed_from_ts),
|
|
revised: Boolean(attempt.snapshot_revised || attempt.revised_snapshot || attempt.revised),
|
|
initiatorTaskId: text(
|
|
attempt.initiator_task_id || attempt.origin_task_id || attempt.task_id,
|
|
),
|
|
executions: normalizedExecutions(attempt.executions, attempt.execution),
|
|
execution: attempt.execution && typeof attempt.execution === 'object' ? attempt.execution : null,
|
|
lifecycleOnly,
|
|
detailRef: skill && jobId ? { surface: 'skill', skill, jobId } : null,
|
|
detailText: '',
|
|
};
|
|
}
|
|
|
|
function normalizeSkillGroup(group, row = {}, { allowRowTaskIdFallback = true } = {}) {
|
|
if (!group || typeof group !== 'object' || text(group.surface) !== 'skill') return null;
|
|
const id = text(group.id || group.group_id);
|
|
const ownerTaskId = text(group.presentation_owner_task_id);
|
|
if (!id || !ownerTaskId) return null;
|
|
const groupStatus = text(group.status);
|
|
const lifecycleStatus = text(
|
|
group.lifecycle_status || row.lifecycle_status || row.job_status
|
|
|| groupStatus || row.status,
|
|
);
|
|
const groupVerdict = text(group.verdict);
|
|
const rowVerdict = text(row.review_status || row.review_verdict || row.status);
|
|
const lifecycleOnly = group.lifecycle_only === true
|
|
|| (!hasSemanticVerdict(groupVerdict || rowVerdict)
|
|
&& LIFECYCLE_TERMINAL_STATES.has(lifecycleStatus.toLowerCase()))
|
|
|| (!group.review_status && !group.review_verdict
|
|
&& groupVerdict.toLowerCase() === lifecycleStatus.toLowerCase()
|
|
&& LIFECYCLE_TERMINAL_STATES.has(lifecycleStatus.toLowerCase()));
|
|
const defaults = {
|
|
skill: text(group.skill || row.skill),
|
|
status: lifecycleOnly
|
|
? text(group.review_status || group.review_verdict)
|
|
: text(group.review_status || group.verdict || group.status || row.status),
|
|
state: normalizedState(
|
|
group.state || (lifecycleOnly ? lifecycleStatus : group.status || row.status),
|
|
),
|
|
initiatorTaskId: text(
|
|
group.initiator_task_id || group.origin_task_id || row.origin_task_id
|
|
|| (allowRowTaskIdFallback ? row.task_id : ''),
|
|
),
|
|
};
|
|
const rawAttempts = Array.isArray(group.attempts) ? group.attempts : [];
|
|
const attempts = rawAttempts
|
|
.map((attempt, index) => normalizeSkillAttempt(attempt, defaults, index))
|
|
.filter(Boolean);
|
|
if (!attempts.length && (row.job_id || group.job_id || group.lifecycle_id)) {
|
|
const fallback = normalizeSkillAttempt({
|
|
...row,
|
|
...group,
|
|
job_id: group.job_id || row.job_id || group.lifecycle_id,
|
|
lifecycle_only: lifecycleOnly,
|
|
}, defaults, 0);
|
|
if (fallback) attempts.push(fallback);
|
|
}
|
|
const latest = attempts[attempts.length - 1] || null;
|
|
const state = normalizedState(
|
|
group.state || group.lifecycle_status || group.status || latest?.state,
|
|
latest?.state || 'unavailable',
|
|
);
|
|
const projectedCount = finiteCount(group.projected_attempt_count);
|
|
const count = projectedCount ?? attempts.length;
|
|
return {
|
|
id,
|
|
surface: 'skill',
|
|
label: 'Skill review',
|
|
subject: defaults.skill,
|
|
presentationOwnerTaskId: ownerTaskId,
|
|
subjectTaskId: text(group.subject_task_id),
|
|
initiatorTaskId: uniformAttemptInitiator(attempts, defaults.initiatorTaskId),
|
|
state,
|
|
tone: lifecycleOnly
|
|
? lifecycleOnlyTone(
|
|
lifecycleStatus || latest?.lifecycleStatus || group.review_status || group.review_verdict,
|
|
state,
|
|
)
|
|
: reviewTone(
|
|
state,
|
|
text(group.verdict || group.review_status || group.status || latest?.verdict),
|
|
lifecycleStatus || latest?.lifecycleStatus,
|
|
),
|
|
verdict: lifecycleOnly
|
|
? text(group.review_status || group.review_verdict || latest?.verdict)
|
|
: text(group.verdict || group.review_status || group.status || latest?.verdict),
|
|
summary: text(group.summary || latest?.summary)
|
|
|| (lifecycleOnly ? 'Review verdict unavailable.' : ''),
|
|
lifecycleOnly,
|
|
lifecycleStatus,
|
|
activeCount: finiteCount(group.active_count) ?? (state === 'queued' || state === 'running' ? 1 : 0),
|
|
attemptCount: Math.max(count, attempts.length),
|
|
countIsAuthoritative: group.count_is_authoritative === true,
|
|
attempts,
|
|
};
|
|
}
|
|
|
|
export function reviewGroupFromHistoryRow(row) {
|
|
return normalizeSkillGroup(row?.review_group, row);
|
|
}
|
|
|
|
export function classifyReviewLifecycle(row) {
|
|
const lifecycle = row?.lifecycle || row?.progress_meta?.lifecycle;
|
|
if (!lifecycle || typeof lifecycle !== 'object' || text(lifecycle.kind) !== 'review') {
|
|
return { classification: 'not_review', group: null };
|
|
}
|
|
const groupId = text(lifecycle.group_id || lifecycle.review_group_id);
|
|
const ownerTaskId = text(lifecycle.presentation_owner_task_id);
|
|
if (!groupId || !ownerTaskId) {
|
|
return { classification: 'source_incomplete', group: null };
|
|
}
|
|
const status = text(lifecycle.status || lifecycle.phase || 'queued');
|
|
const reviewStatus = text(lifecycle.review_status || lifecycle.review_verdict);
|
|
const lifecycleOnly = !reviewStatus;
|
|
const timestamp = text(
|
|
lifecycle.finished_at || lifecycle.started_at || lifecycle.queued_at
|
|
|| lifecycle.ts || row?.ts || row?.timestamp,
|
|
);
|
|
const group = normalizeSkillGroup({
|
|
surface: 'skill',
|
|
id: groupId,
|
|
presentation_owner_task_id: ownerTaskId,
|
|
subject_task_id: lifecycle.subject_task_id,
|
|
initiator_task_id: lifecycle.initiator_task_id || lifecycle.origin_task_id,
|
|
skill: lifecycle.target || lifecycle.skill,
|
|
state: status,
|
|
status: reviewStatus,
|
|
review_status: reviewStatus,
|
|
lifecycle_status: status,
|
|
lifecycle_only: lifecycleOnly,
|
|
active_count: ['pending', 'queued', 'running'].includes(status.toLowerCase()) ? 1 : 0,
|
|
projected_attempt_count: lifecycle.projected_attempt_count ?? (lifecycle.job_id || lifecycle.id ? 1 : 0),
|
|
count_is_authoritative: lifecycle.count_is_authoritative === true,
|
|
attempts: lifecycle.job_id || lifecycle.id ? [{
|
|
...lifecycle,
|
|
ts: timestamp,
|
|
job_id: lifecycle.job_id || lifecycle.id,
|
|
status: reviewStatus,
|
|
review_status: reviewStatus,
|
|
lifecycle_status: status,
|
|
lifecycle_only: lifecycleOnly,
|
|
}] : [],
|
|
}, row, { allowRowTaskIdFallback: false });
|
|
return {
|
|
classification: group ? 'source_complete' : 'source_incomplete',
|
|
group,
|
|
};
|
|
}
|
|
|
|
export function reviewGroupFromLifecycle(row) {
|
|
return classifyReviewLifecycle(row).group;
|
|
}
|
|
|
|
function lifecyclePointerPayload(row) {
|
|
const direct = row?.lifecycle_pointer;
|
|
if (direct && typeof direct === 'object') return direct;
|
|
const nested = row?.progress_meta?.lifecycle_pointer;
|
|
return nested && typeof nested === 'object' ? nested : null;
|
|
}
|
|
|
|
export function classifyReviewLifecyclePointer(row) {
|
|
const pointer = lifecyclePointerPayload(row);
|
|
if (!pointer || text(pointer.kind) !== 'review') {
|
|
return { classification: 'not_pointer', group: null };
|
|
}
|
|
const groupId = text(pointer.group_id || pointer.review_group_id);
|
|
const ownerTaskId = text(pointer.presentation_owner_task_id);
|
|
if (!groupId || !ownerTaskId) {
|
|
return { classification: 'source_incomplete', group: null };
|
|
}
|
|
const status = text(pointer.status || 'queued');
|
|
const reviewStatus = text(pointer.review_status || pointer.review_verdict);
|
|
const lifecycleOnly = !reviewStatus;
|
|
const initiatorTaskId = text(pointer.initiator_task_id || pointer.origin_task_id);
|
|
const timestamp = text(
|
|
pointer.finished_at || pointer.started_at || pointer.queued_at
|
|
|| pointer.ts || row?.ts || row?.timestamp,
|
|
);
|
|
const group = normalizeSkillGroup({
|
|
surface: 'skill',
|
|
id: groupId,
|
|
presentation_owner_task_id: ownerTaskId,
|
|
subject_task_id: pointer.subject_task_id,
|
|
initiator_task_id: initiatorTaskId,
|
|
skill: pointer.target || pointer.skill,
|
|
state: status,
|
|
status: reviewStatus,
|
|
review_status: reviewStatus,
|
|
lifecycle_status: status,
|
|
lifecycle_only: lifecycleOnly,
|
|
active_count: ['pending', 'queued', 'running'].includes(status.toLowerCase()) ? 1 : 0,
|
|
projected_attempt_count: pointer.projected_attempt_count ?? (pointer.job_id ? 1 : 0),
|
|
count_is_authoritative: pointer.count_is_authoritative === true,
|
|
attempts: pointer.job_id ? [{
|
|
...pointer,
|
|
ts: timestamp,
|
|
initiator_task_id: initiatorTaskId,
|
|
status: reviewStatus,
|
|
review_status: reviewStatus,
|
|
lifecycle_status: status,
|
|
lifecycle_only: lifecycleOnly,
|
|
}] : [],
|
|
}, row, { allowRowTaskIdFallback: false });
|
|
return {
|
|
classification: group ? 'source_complete' : 'source_incomplete',
|
|
group,
|
|
};
|
|
}
|
|
|
|
function planAttempt(wave, index, isCurrent, live) {
|
|
if (!wave || typeof wave !== 'object') return null;
|
|
const fingerprint = text(wave.request_fingerprint);
|
|
const id = attemptIdentity(wave, `${fingerprint || 'wave'}:${wave.cycle_index ?? index + 1}`);
|
|
if (!id) return null;
|
|
const verdict = text(wave.aggregate || 'UNKNOWN');
|
|
const superseded = Boolean(wave.superseded) || !isCurrent;
|
|
// Gate closure and physical reviewer custody are independent: a partial wave can stay in flight after the
|
|
// author selects a different plan — but only while its own task still runs to collect it; afterwards it is a gap.
|
|
const custodyPending = wave.custody_pending === true;
|
|
const state = custodyPending && live ? 'running' : superseded ? 'superseded' : 'terminal';
|
|
const roster = (Array.isArray(wave.actors) ? wave.actors : [])
|
|
.filter((actor) => actor && typeof actor === 'object');
|
|
// A wave whose reviewers may still answer reports how far it got; a settled wave
|
|
// whose reviewers were too few for a verdict reports the same counts in the neutral
|
|
// tone (the stored DEGRADED word is the host's placeholder and never paints).
|
|
const settledNoQuorum = !custodyPending && verdict === 'DEGRADED';
|
|
const [progress, heldTone] = custodyPending || settledNoQuorum ? heldProgress(
|
|
custodyPending && roster.some(actorUnresolved) && !roster.some(actorAwaiting) ? 'unresolved' : (custodyPending && live ? 'in progress' : 'no verdict'), roster, (actor) => actor.ok === true) : ['', ''];
|
|
return {
|
|
id,
|
|
surface: 'plan',
|
|
state,
|
|
progress,
|
|
tone: settledNoQuorum ? 'neutral' : heldTone || statusTone(state, custodyPending ? '' : verdict),
|
|
verdict,
|
|
timestamp: text(wave.reviewed_at || wave.ts || wave.timestamp || wave.closed_at),
|
|
ordinal: index,
|
|
label: wave.cycle_index != null ? `wave ${wave.cycle_index}` : `wave ${index + 1}`,
|
|
summary: text(wave.reason || wave.summary),
|
|
superseded,
|
|
compact: Boolean(wave.compact),
|
|
replayed: Boolean(wave.replayed || wave.cached),
|
|
revised: Boolean(wave.revised),
|
|
initiatorTaskId: '',
|
|
executions: executionsFromReviewRecord(wave),
|
|
execution: null,
|
|
detailRef: null,
|
|
detailText: `${planWaveDetail(wave)}\nCost unavailable`.trim(),
|
|
};
|
|
}
|
|
|
|
function currentPlanAttempt(current, index) {
|
|
const fingerprint = text(current?.fingerprint);
|
|
const status = text(current?.status).toLowerCase();
|
|
if (!fingerprint || !status) return null;
|
|
const state = normalizedState(status);
|
|
const detailText = [
|
|
`Status: ${status}`,
|
|
current.reason ? `Reason: ${text(current.reason)}` : '',
|
|
'Review result unavailable.',
|
|
'Cost unavailable',
|
|
].filter(Boolean).join('\n');
|
|
return {
|
|
id: fingerprint,
|
|
surface: 'plan',
|
|
state,
|
|
tone: statusTone(state, status),
|
|
verdict: status,
|
|
timestamp: text(current.ts || current.timestamp),
|
|
ordinal: index,
|
|
label: 'current attempt',
|
|
summary: text(current.reason),
|
|
superseded: false,
|
|
compact: false,
|
|
replayed: false,
|
|
revised: false,
|
|
initiatorTaskId: '',
|
|
executions: normalizedExecutions(current.executions, current.execution),
|
|
execution: null,
|
|
detailRef: null,
|
|
detailText,
|
|
};
|
|
}
|
|
|
|
function planFindingLines(wave) {
|
|
const findings = (Array.isArray(wave.findings) ? wave.findings : [])
|
|
.filter((item) => item && typeof item === 'object');
|
|
const dispositions = (Array.isArray(wave.dispositions) ? wave.dispositions : [])
|
|
.filter((item) => item && typeof item === 'object');
|
|
// EVERY disposition row renders: the backend refuses duplicate rows for
|
|
// one finding as contradictory intent and keeps the finding open, so
|
|
// showing only the first would present a refused decision as operative.
|
|
const dispositionsByFinding = new Map();
|
|
for (const disposition of dispositions) {
|
|
const key = text(disposition.finding_id);
|
|
const bucket = dispositionsByFinding.get(key) || [];
|
|
bucket.push(disposition);
|
|
dispositionsByFinding.set(key, bucket);
|
|
}
|
|
const dispositionLine = (disposition, prefix) => (
|
|
`${prefix}${text(disposition.decision) || 'disposition'}${text(disposition.rationale) ? ` — ${text(disposition.rationale)}` : ''}`
|
|
);
|
|
const lines = [];
|
|
for (const finding of findings) {
|
|
const head = [
|
|
`[${text(finding.class) || 'finding'}] ${text(finding.summary) || '(no summary)'}`,
|
|
text(finding.breaks) ? `breaks ${text(finding.breaks)}` : '',
|
|
text(finding.locator) ? `at ${text(finding.locator)}` : '',
|
|
].filter(Boolean).join(' — ');
|
|
const source = [text(finding.slot), text(finding.model)].filter(Boolean).join(' · ');
|
|
lines.push(source ? `${head} — ${source}` : head);
|
|
if (text(finding.recommendation)) lines.push(` fix: ${text(finding.recommendation)}`);
|
|
const findingId = text(finding.finding_id);
|
|
if (findingId) {
|
|
for (const disposition of dispositionsByFinding.get(findingId) || []) {
|
|
lines.push(dispositionLine(disposition, ' agent: '));
|
|
}
|
|
dispositionsByFinding.delete(findingId);
|
|
}
|
|
}
|
|
if (dispositionsByFinding.size) {
|
|
lines.push('General dispositions:');
|
|
for (const [findingId, bucket] of dispositionsByFinding) {
|
|
for (const disposition of bucket) {
|
|
lines.push(dispositionLine(disposition, ` ${findingId || '(no finding id)'}: `));
|
|
}
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function planActorAvailabilityLines(wave) {
|
|
// The bug report's own bar: a result that was never received must say so
|
|
// explicitly instead of contributing silently-zero findings. A row names the model and
|
|
// quotes the engine's sentence; failure code and slot id stay in the task detail and Logs.
|
|
const lines = [];
|
|
for (const actor of (Array.isArray(wave.actors) ? wave.actors : [])) {
|
|
if (!actor || typeof actor !== 'object' || actor.ok !== false) continue;
|
|
const model = text(actor.model) || 'reviewer';
|
|
const cause = text(actor.reported_cause).split(/\s+/).join(' ');
|
|
const carried = finiteCount(actor.carried_findings) ? '; its earlier finding is still listed' : '';
|
|
if (actorAwaiting(actor)) lines.push(`${model} · awaiting${sinceLocalTime(actor.awaiting_since)}`);
|
|
else if (actorUnresolved(actor)) lines.push(`${model} · no answer${cause ? ` — "${cause}"` : ''}${sinceLocalTime(actor.awaiting_since)}`);
|
|
else if (text(actor.operation_state) === 'not_dispatched') lines.push(`${model} · not sent${carried}`);
|
|
else lines.push(`${model} · unavailable${cause ? ` — "${cause}"` : ''}${carried ? ` · did not answer${carried}` : ''}`);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function planWaveDetail(wave) {
|
|
const lines = [
|
|
wave.custody_pending === true
|
|
? 'Verdict: none (wave held open)'
|
|
: (text(wave.aggregate) === 'DEGRADED' ? 'Verdict: none — fewer reviewers answered than needed' : (wave.aggregate ? `Verdict: ${wave.aggregate}` : '')),
|
|
wave.closed != null ? `Closed: ${wave.closed ? 'yes' : 'no'}` : '',
|
|
wave.paid != null ? `Reviewer panel dispatched: ${wave.paid ? 'yes' : 'no'}` : '',
|
|
wave.quorum_unreachable ? 'Quorum unavailable' : '',
|
|
wave.cycles_exhausted ? 'Review cycles exhausted' : '',
|
|
wave.reason ? `Reason: ${text(wave.reason)}` : '',
|
|
];
|
|
const counts = wave.counts && typeof wave.counts === 'object' ? wave.counts : {};
|
|
// A panel ordered weaker than the owner's setting says so seat by seat (typed fact; the
|
|
// verdict token is never recoloured), on compact waves too since compaction keeps the fact.
|
|
const weaker = wave.ordered_weaker && typeof wave.ordered_weaker === 'object'
|
|
? Object.entries(wave.ordered_weaker).filter(([, row]) => row && typeof row === 'object') : [];
|
|
if (weaker.length) {
|
|
lines.push(`Reviewers ordered weaker than your setting: ${weaker
|
|
.map(([sid, row]) => `${sid} ${text(row.effort) || '?'} (setting ${text(row.owner_effort) || '?'})`).join(', ')}`);
|
|
}
|
|
if (wave.compact) {
|
|
// A compacted wave keeps counts while its finding bodies moved to the
|
|
// immutable wave artifact; name that remainder instead of rendering a
|
|
// bound that looks like the whole record.
|
|
const recorded = [
|
|
finiteCount(counts.findings) != null ? `${finiteCount(counts.findings)} findings` : '',
|
|
finiteCount(counts.blocking) != null ? `${finiteCount(counts.blocking)} blocking` : '',
|
|
finiteCount(counts.dispositions) != null ? `${finiteCount(counts.dispositions)} dispositions` : '',
|
|
].filter(Boolean).join(' · ');
|
|
if (recorded) lines.push(`Recorded: ${recorded}`);
|
|
const artifact = wave.wave_artifact && typeof wave.wave_artifact === 'object' ? wave.wave_artifact : {};
|
|
const sha = text(artifact.sha256);
|
|
lines.push(`Finding bodies compacted${sha ? ` · artifact sha256=${sha.slice(0, 12)}…` : ''}${finiteCount(artifact.bytes) != null ? ` (${finiteCount(artifact.bytes)} bytes)` : ''}`);
|
|
return lines.filter(Boolean).join('\n');
|
|
}
|
|
const countParts = ['blocking', 'note', 'need_evidence']
|
|
.filter((key) => finiteCount(counts[key]) != null)
|
|
.map((key) => `${finiteCount(counts[key])} ${key}`);
|
|
if (countParts.length) lines.push(`Findings: ${countParts.join(' · ')}`);
|
|
lines.push(...planFindingLines(wave));
|
|
lines.push(...planActorAvailabilityLines(wave));
|
|
const findingsShown = (Array.isArray(wave.findings) ? wave.findings : []).length;
|
|
if (wave.findings_paged && finiteCount(wave.findings_total) != null) {
|
|
lines.push(`Showing ${findingsShown} of ${finiteCount(wave.findings_total)} findings (per-slot page cap)`);
|
|
}
|
|
if (wave.findings_texts_truncated) lines.push('Some finding texts were truncated at capture.');
|
|
if (wave.spec_body_truncated) lines.push('Spec body was truncated at capture.');
|
|
const author = wave.author_disposition;
|
|
if (author && typeof author === 'object' && text(author.disposition)) {
|
|
lines.push(
|
|
`Author ${author.action === 'stop' ? 'stop' : 'finish'}: ${text(author.disposition)}${text(author.reviewer_signal) ? ` · reviewer signal=${text(author.reviewer_signal)}` : ''}`
|
|
+ `${text(author.rationale) ? ` · ${text(author.rationale)}` : ''}`
|
|
+ `${text(author.subject_hash) ? ` · subject_hash=${text(author.subject_hash)}` : ''}`
|
|
+ `${text(author.source) ? ` · source=${text(author.source)}` : ''}`,
|
|
);
|
|
}
|
|
return lines.filter(Boolean).join('\n');
|
|
}
|
|
|
|
function legacyPlanReviewGroup(owner, stateRecord) {
|
|
const projection = stateRecord?.legacy_v1_projection;
|
|
if (!projection || typeof projection !== 'object') return null;
|
|
const status = text(projection.status).toLowerCase();
|
|
if (!status || status === 'absent') return null;
|
|
const outcome = text(projection.outcome);
|
|
const fingerprint = text(projection.fingerprint);
|
|
const attempts = fingerprint && outcome ? [{
|
|
id: fingerprint,
|
|
surface: 'plan',
|
|
state: 'terminal',
|
|
tone: statusTone('terminal', outcome),
|
|
verdict: outcome,
|
|
timestamp: '',
|
|
ordinal: 0,
|
|
label: 'legacy wave',
|
|
summary: text(projection.reason),
|
|
superseded: false,
|
|
compact: false,
|
|
replayed: false,
|
|
revised: false,
|
|
initiatorTaskId: '',
|
|
executions: [],
|
|
execution: null,
|
|
detailRef: null,
|
|
detailText: [
|
|
`Status: ${status}`,
|
|
`Verdict: ${outcome}`,
|
|
projection.closed != null ? `Closed: ${projection.closed ? 'yes' : 'no'}` : '',
|
|
projection.reason ? `Reason: ${text(projection.reason)}` : '',
|
|
'Cost unavailable',
|
|
].filter(Boolean).join('\n'),
|
|
}] : [];
|
|
const pending = status === 'pending';
|
|
const controlVerdict = status === 'rail_degraded' ? status : outcome || status;
|
|
const state = pending ? 'queued'
|
|
: (status === 'open' && !outcome ? 'unavailable' : 'terminal');
|
|
return {
|
|
id: `plan:${owner}`,
|
|
surface: 'plan',
|
|
label: 'Plan review',
|
|
subject: '',
|
|
presentationOwnerTaskId: owner,
|
|
subjectTaskId: owner,
|
|
initiatorTaskId: owner,
|
|
state,
|
|
tone: statusTone(state, outcome || (status === 'open' ? 'unavailable' : controlVerdict)),
|
|
verdict: controlVerdict,
|
|
summary: text(projection.reason),
|
|
activeCount: pending ? 1 : 0,
|
|
attemptCount: attempts.length,
|
|
countIsAuthoritative: false,
|
|
attempts,
|
|
};
|
|
}
|
|
|
|
export function planReviewGroupFromTaskDetail(detail, ownerTaskId = '') {
|
|
const owner = text(ownerTaskId || detail?.task_id);
|
|
const stateRecord = detail?.plan_review_state;
|
|
if (!owner || !stateRecord || typeof stateRecord !== 'object') return null;
|
|
if (stateRecord.schema_version === 1) return legacyPlanReviewGroup(owner, stateRecord);
|
|
const current = stateRecord.current_attempt && typeof stateRecord.current_attempt === 'object'
|
|
? stateRecord.current_attempt : {};
|
|
const recordedWaves = Array.isArray(stateRecord.waves) ? stateRecord.waves : [];
|
|
if (!recordedWaves.length && !text(current.status)) return null;
|
|
const currentFingerprint = text(current.fingerprint);
|
|
const authorSubject = current.author_subject;
|
|
const author = authorDispositionText(authorSubject?.author_disposition);
|
|
const reviewFingerprint = author ? text(authorSubject.review_fingerprint) : currentFingerprint;
|
|
// The critic reviewed the EARLIER plan when the author selected a revised one: the group
|
|
// is labelled as that plan's review and the selected plan is named unreviewed — the same
|
|
// `historical_critic` fact the gate projection carries, never a synthesized verdict.
|
|
const historicalCritic = Boolean(author) && Boolean(reviewFingerprint) && reviewFingerprint !== currentFingerprint;
|
|
const authorDecisionText = author ? [author,
|
|
`Critic plan: ${reviewFingerprint}`,
|
|
historicalCritic ? `The verdict shown is the earlier plan's review; the selected plan ${currentFingerprint} has no verdict of its own` : '',
|
|
authorSubject.source_ref?.path ? `Current plan source: ${text(authorSubject.source_ref.root)}:${text(authorSubject.source_ref.path)}` : '',
|
|
authorSubject.source_ref?.sha256 ? `Source sha256=${text(authorSubject.source_ref.sha256)}` : '',
|
|
].filter(Boolean).join('\n') : '';
|
|
const typedCurrentStatus = text(current.status).toLowerCase();
|
|
// C-09: a compact row proves history, not reusable authority. While the
|
|
// same envelope is being reviewed again, replace that stale projection
|
|
// with current_attempt; the eventual full wave reuses the same identity.
|
|
const waves = recordedWaves.filter((wave) => !(
|
|
wave?.compact && !author
|
|
&& currentFingerprint
|
|
&& typedCurrentStatus
|
|
&& text(wave.request_fingerprint) === currentFingerprint
|
|
&& (typedCurrentStatus === 'open' || wave.closed === true)
|
|
));
|
|
const currentWaveIndex = reviewFingerprint
|
|
? waves.findIndex((wave) => text(wave?.request_fingerprint) === reviewFingerprint)
|
|
: (waves.length ? waves.length - 1 : -1);
|
|
const attempts = waves
|
|
.map((wave, index) => planAttempt(
|
|
wave,
|
|
index,
|
|
index === currentWaveIndex, text(detail?.status).toLowerCase() === 'running',
|
|
))
|
|
.filter(Boolean);
|
|
let currentAttempt = currentWaveIndex >= 0
|
|
? attempts.find((attempt) => attempt.id === attemptIdentity(
|
|
waves[currentWaveIndex],
|
|
`${currentFingerprint || 'wave'}:${waves[currentWaveIndex]?.cycle_index ?? currentWaveIndex + 1}`,
|
|
)) || null
|
|
: null;
|
|
const currentStatus = typedCurrentStatus || (currentAttempt ? 'closed' : 'open');
|
|
let state = 'terminal';
|
|
let activeCount = 0;
|
|
if (!currentAttempt && !author) {
|
|
const unmatchedAttempt = currentPlanAttempt(current, attempts.length);
|
|
if (unmatchedAttempt) {
|
|
attempts.push(unmatchedAttempt);
|
|
currentAttempt = unmatchedAttempt;
|
|
state = unmatchedAttempt.state;
|
|
activeCount = state === 'queued' || state === 'running' ? 1 : 0;
|
|
} else if (currentStatus) {
|
|
state = normalizedState(currentStatus);
|
|
}
|
|
}
|
|
let currentVerdict = text(currentAttempt?.verdict || (author ? 'unavailable' : currentStatus));
|
|
// The typed Plan gate releases an open wave when the task-wide deadline
|
|
// rail degrades. Preserve the wave as semantic review evidence, but mirror
|
|
// the backend precedence in the group header. A closed wave remains final.
|
|
const terminalControl = typedCurrentStatus === 'rail_degraded'
|
|
? typedCurrentStatus
|
|
: (waves[currentWaveIndex]?.cycles_exhausted === true ? 'cycles_exhausted' : '');
|
|
if (currentAttempt && terminalControl && waves[currentWaveIndex]?.closed !== true) {
|
|
state = normalizedState(terminalControl);
|
|
activeCount = 0;
|
|
currentVerdict = terminalControl;
|
|
}
|
|
const activeAttempts = attempts.filter((attempt) => ['queued', 'running'].includes(attempt.state));
|
|
if (activeAttempts.length) {
|
|
state = activeAttempts.some((attempt) => attempt.state === 'running') ? 'running' : 'queued';
|
|
activeCount = activeAttempts.length;
|
|
}
|
|
// The group header speaks for its CURRENT wave: a progress phrase while that wave
|
|
// has unanswered slots, the recorded verdict once every slot is collected.
|
|
const progress = text(currentAttempt?.progress);
|
|
return {
|
|
id: `plan:${owner}`,
|
|
surface: 'plan',
|
|
label: historicalCritic ? 'Plan review · earlier plan' : 'Plan review',
|
|
historicalCritic,
|
|
subject: '',
|
|
presentationOwnerTaskId: owner,
|
|
subjectTaskId: owner,
|
|
initiatorTaskId: owner,
|
|
state,
|
|
progress,
|
|
tone: progress ? currentAttempt.tone : statusTone(state, currentVerdict),
|
|
verdict: currentVerdict,
|
|
summary: text(current.reason || currentAttempt?.summary),
|
|
authorDecisionText,
|
|
activeCount,
|
|
attemptCount: attempts.length + (finiteCount(stateRecord.waves_omitted) || 0),
|
|
countIsAuthoritative: finiteCount(stateRecord.waves_omitted) === 0,
|
|
attempts,
|
|
};
|
|
}
|
|
|
|
function compactCoverage(coverage) {
|
|
if (!coverage || typeof coverage !== 'object') return '';
|
|
return Object.entries(coverage)
|
|
.filter(([, value]) => value !== '' && value !== null && value !== undefined)
|
|
.map(([key, value]) => `${key}=${String(value)}`)
|
|
.join(', ');
|
|
}
|
|
|
|
export function formatReviewProjection(projection) {
|
|
const panels = Array.isArray(projection?.panels) ? projection.panels : [];
|
|
const lines = [];
|
|
panels.forEach((panel, panelIndex) => {
|
|
if (!panel || typeof panel !== 'object') return;
|
|
const quorum = panel.quorum && typeof panel.quorum === 'object' ? panel.quorum : {};
|
|
const panelId = String(panel.panel_id || `panel-${panelIndex + 1}`);
|
|
const awaiting = (Array.isArray(panel.actors) ? panel.actors : []).filter(actorAwaiting).length;
|
|
const signal = String(panel.aggregate_signal || 'UNKNOWN');
|
|
// While a slot is awaited the aggregate is not final; DEGRADED is only the host's placeholder.
|
|
const verdictText = !awaiting ? signal : (signal === 'DEGRADED' ? `none (${awaiting} awaiting; held as DEGRADED)` : `${signal} (${awaiting} awaiting)`);
|
|
lines.push(
|
|
`Review panel ${panelId}: ${String(panel.surface || 'review')} · authority=${String(panel.authority || 'unspecified')} · verdict=${verdictText} · transport=${String(panel.transport_status || 'unknown')} · parse=${String(panel.parse_status || 'unknown')} · quorum=${String(quorum.contributed ?? 0)}/${String(quorum.configured ?? 0)} (required ${String(quorum.required ?? 0)}) · enforcement=${String(panel.enforcement_impact || 'unknown')}${panel.single_reviewer_no_diversity ? ' · single-reviewer (no diversity)' : ''}${panel.dialogue && panel.dialogue.status ? ` · dialogue=${String(panel.dialogue.status)}` : ''}${panel.superseded ? ' · superseded' : ''}`,
|
|
);
|
|
if (panel.reason) lines.push(`Panel reason: ${String(panel.reason)}`);
|
|
const coverage = compactCoverage(panel.coverage);
|
|
if (coverage) lines.push(`Panel coverage: ${coverage}`);
|
|
const binding = [
|
|
panel.candidate_hash ? `candidate_hash=${String(panel.candidate_hash)}` : '',
|
|
panel.evidence_revision ? `evidence_revision=${String(panel.evidence_revision)}` : '',
|
|
panel.fence_hash ? `fence_hash=${String(panel.fence_hash)}` : '',
|
|
panel.binding_hash ? `binding_hash=${String(panel.binding_hash)}` : '',
|
|
].filter(Boolean);
|
|
if (binding.length) lines.push(`Panel binding: ${binding.join(' · ')}`);
|
|
(Array.isArray(panel.actors) ? panel.actors : []).forEach((actor) => {
|
|
if (!actor || typeof actor !== 'object') return;
|
|
const slotId = String(actor.slot_id || '?');
|
|
lines.push(
|
|
`Reviewer ${slotId}: role=${String(actor.actor_role || 'reviewer')} · provider=${String(actor.provider || 'unknown')} · model=${String(actor.model || 'unknown')} · transport=${actorAwaiting(actor) ? 'awaiting' : String(actor.transport_status || 'unknown')} · parse=${actorAwaiting(actor) ? 'awaiting' : String(actor.parse_status || 'unknown')} · verdict=${String(actor.semantic_verdict || 'none')}${actor.outcome_tier ? ` · outcome_tier=${String(actor.outcome_tier)}` : ''}${actor.dialogue_status ? ` · dialogue=${String(actor.dialogue_status)}` : ''} · quorum=${actor.quorum_contribution ? 'contributes' : 'abstains'} · enforcement=${String(actor.enforcement_impact || 'unknown')}${actorAwaiting(actor) || actorUnresolved(actor) ? sinceLocalTime(actor.awaiting_since) : ''}`,
|
|
);
|
|
const actorCoverage = compactCoverage(actor.coverage);
|
|
if (actorCoverage) lines.push(`Reviewer ${slotId} coverage: ${actorCoverage}`);
|
|
if (actor.reason) lines.push(`Reviewer ${slotId} reason: ${String(actor.reason)}`);
|
|
if (Array.isArray(actor.findings)) {
|
|
for (const finding of actor.findings) {
|
|
if (!finding || typeof finding !== 'object') continue;
|
|
const label = [text(finding.severity), text(finding.verdict)]
|
|
.filter(Boolean).join(' ') || 'finding';
|
|
const title = text(finding.item) || text(finding.summary) || '(no item)';
|
|
const summaryText = text(finding.summary);
|
|
const body = [
|
|
`[${label}]${text(finding.id) ? ` ${text(finding.id)}` : ''} ${title}`,
|
|
summaryText && summaryText !== title ? `summary: ${summaryText}` : '',
|
|
text(finding.reason) ? `reason: ${text(finding.reason)}` : '',
|
|
text(finding.evidence) ? `evidence: ${text(finding.evidence)}` : '',
|
|
text(finding.recommendation) ? `fix: ${text(finding.recommendation)}` : '',
|
|
].filter(Boolean).join(' — ');
|
|
lines.push(`Reviewer ${slotId} finding: ${body}`);
|
|
}
|
|
const omitted = finiteCount(actor.findings_omitted);
|
|
if (omitted) lines.push(`Reviewer ${slotId} findings omitted: ${omitted}`);
|
|
}
|
|
// P1: name the durable full copy unconditionally — bounded rows,
|
|
// per-string truncation markers and pre-findings-era projections
|
|
// all resolve through the same observability call.
|
|
const callId = text(actor.response_ref?.call_id);
|
|
if (callId) {
|
|
lines.push(`Reviewer ${slotId} full response: observability call ${callId}`);
|
|
}
|
|
});
|
|
});
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function authorDispositionText(author, label = '') {
|
|
if (!author || typeof author !== 'object' || (!text(author.disposition) && !text(author.action))) return '';
|
|
const actionLabel = label || `Author ${author.action === 'stop' ? 'stop' : 'finish'}`;
|
|
return [
|
|
`${actionLabel}${text(author.disposition) ? `: ${text(author.disposition)}` : ''}`,
|
|
text(author.reviewer_signal) ? `reviewer signal=${text(author.reviewer_signal)}` : '',
|
|
text(author.rationale),
|
|
text(author.subject_hash) ? `subject_hash=${text(author.subject_hash)}` : '',
|
|
text(author.source) ? `source=${text(author.source)}` : '',
|
|
].filter(Boolean).join(' · ');
|
|
}
|
|
|
|
export function taskAcceptanceGroupFromTaskDetail(detail, ownerTaskId = '') {
|
|
const owner = text(ownerTaskId || detail?.task_id);
|
|
const projection = detail?.review_projection;
|
|
const panels = (Array.isArray(projection?.panels) ? projection.panels : [])
|
|
.filter((panel) => text(panel?.surface) === 'task_acceptance');
|
|
const incident = acceptanceIncidentFromTaskDetail(detail);
|
|
// A local preparation failure produces NO panel: the group has to exist on
|
|
// the incident alone, or the owner sees nothing at all.
|
|
if (!owner || (!panels.length && !incident)) return null;
|
|
const acceptanceDecision = detail?.outcome_axes?.review?.acceptance_decision
|
|
|| detail?.review_status?.acceptance_decision;
|
|
const decisionAuthor = acceptanceDecision?.author_disposition;
|
|
// A task's final author subject can differ from every historical panel:
|
|
// Advisory may finish edited h2 after the critic reviewed h1. Keep this
|
|
// decision at task level; it never belongs to a panel merely by recency.
|
|
const authorDecisionText = authorDispositionText(decisionAuthor)
|
|
|| authorDispositionText({
|
|
disposition: acceptanceDecision?.agent_disposition,
|
|
rationale: acceptanceDecision?.agent_rationale
|
|
|| acceptanceDecision?.author_rationale || acceptanceDecision?.rationale,
|
|
source: acceptanceDecision?.source,
|
|
}, 'Author stance (unbound)');
|
|
const attempts = panels.map((panel, index) => {
|
|
const verdict = text(panel?.aggregate_signal || 'UNKNOWN');
|
|
const roster = (Array.isArray(panel?.actors) ? panel.actors : []).filter((actor) => actor && typeof actor === 'object');
|
|
const awaited = roster.some(actorAwaiting);
|
|
// An awaited panel is activity only while its own task still runs to collect it;
|
|
// afterwards an unanswered slot is a recorded gap, not a degraded verdict.
|
|
const live = awaited && text(detail?.status).toLowerCase() === 'running' && !panel?.superseded && index === panels.length - 1;
|
|
const held = { FAIL: 'FAIL', PASS: live ? 'PASS so far' : 'PASS' }[verdict.toUpperCase()] || (live ? 'in progress' : 'no verdict');
|
|
const [progress, heldTone] = awaited ? heldProgress(held, roster, (actor) => text(actor.transport_status) === 'success') : ['', ''];
|
|
// The host composed this sentence; the card prints it verbatim and
|
|
// leads the detail with it, because the renderer shows detailText over
|
|
// summary. The panel's own verdict and identity are untouched.
|
|
const late = panel?.late_settlement && typeof panel.late_settlement === 'object' ? panel.late_settlement : null;
|
|
const lateNote = late ? text(late.note) : '';
|
|
return {
|
|
id: [attemptIdentity(panel, `panel:${index + 1}`),
|
|
panel.task_attempt == null ? '' : `task-attempt:${panel.task_attempt}`,
|
|
panel.panel_index == null ? '' : `panel-index:${panel.panel_index}`].filter(Boolean).join(':'),
|
|
surface: 'task_acceptance',
|
|
state: live ? 'running' : (panel?.superseded ? 'superseded' : 'terminal'),
|
|
progress,
|
|
tone: !awaited || held === 'FAIL' ? statusTone('terminal', verdict)
|
|
: (heldTone || (live ? 'working' : (held === 'PASS' ? statusTone('terminal', verdict) : 'neutral'))),
|
|
verdict,
|
|
timestamp: text(panel?.ts || panel?.timestamp),
|
|
ordinal: index,
|
|
label: `panel ${text(panel?.panel_id || index + 1)}${late ? ' · settled after the task ended' : ''}`,
|
|
summary: lateNote || text(panel?.reason),
|
|
superseded: Boolean(panel?.superseded),
|
|
replayed: false,
|
|
revised: false,
|
|
initiatorTaskId: owner,
|
|
executions: executionsFromReviewRecord(panel),
|
|
execution: null,
|
|
detailRef: { surface: 'task_acceptance', url: panel.applied_source_status === 'available'
|
|
? taskSourceDownloadUrl(owner, panel.applied_source_ref) : '' },
|
|
detailText: [lateNote, formatReviewProjection({ panels: [panel] }),
|
|
authorDispositionText(panel.author_disposition), 'Cost unavailable'].filter(Boolean).join('\n'),
|
|
};
|
|
});
|
|
return acceptanceGroupWithIncident({ owner, attempts, incident, authorDecisionText, statusTone });
|
|
}
|
|
|
|
export function reviewGroupsFromTaskDetail(detail, ownerTaskId = '') {
|
|
return [
|
|
planReviewGroupFromTaskDetail(detail, ownerTaskId),
|
|
taskAcceptanceGroupFromTaskDetail(detail, ownerTaskId),
|
|
].filter(Boolean);
|
|
}
|
|
|
|
export function mergeReviewGroup(store, incoming) {
|
|
if (!(store instanceof Map) || !incoming?.id || !incoming.presentationOwnerTaskId) return null;
|
|
const prior = store.get(incoming.id);
|
|
if (!prior) {
|
|
const created = { ...incoming, attempts: [...incoming.attempts] };
|
|
store.set(created.id, created);
|
|
return created;
|
|
}
|
|
const incomingIds = new Set(incoming.attempts.map((attempt) => attempt.id));
|
|
const priorById = new Map(prior.attempts.map((attempt) => [attempt.id, attempt]));
|
|
const mergedById = new Map(priorById);
|
|
let hasStaleActiveAttempt = false;
|
|
let introducedActiveAttempt = false;
|
|
for (const attempt of incoming.attempts) {
|
|
const previous = priorById.get(attempt.id);
|
|
const previousTerminal = previous?.state === 'terminal' || previous?.state === 'superseded';
|
|
const incomingActive = attempt.state === 'queued' || attempt.state === 'running';
|
|
const replacesCompactedPlan = (
|
|
incoming.surface === 'plan'
|
|
&& previous?.compact === true
|
|
&& attempt.compact === false
|
|
&& incomingActive
|
|
);
|
|
// Attempt ids are immutable domain references. A delayed lifecycle row
|
|
// for the same attempt may arrive after its terminal history row, but
|
|
// it cannot make that physical attempt non-terminal again.
|
|
let mergedAttempt = previousTerminal && incomingActive && !replacesCompactedPlan
|
|
? { ...attempt, ...previous }
|
|
: { ...(previous || {}), ...attempt };
|
|
// A lifecycle terminal frame can arrive after the domain history row
|
|
// for the same attempt. It contributes timing/execution facts, but
|
|
// its `succeeded`/`completed` word is not a review verdict and must
|
|
// not erase an already-proved semantic result.
|
|
if (
|
|
attempt.lifecycleOnly
|
|
&& previousTerminal
|
|
&& hasSemanticVerdict(previous?.verdict)
|
|
) {
|
|
mergedAttempt = {
|
|
...mergedAttempt,
|
|
state: previous.state,
|
|
tone: lifecycleFailure(attempt.lifecycleStatus)
|
|
? lifecycleOnlyTone(attempt.lifecycleStatus, previous.state)
|
|
: previous.tone,
|
|
verdict: previous.verdict,
|
|
summary: previous.summary || attempt.summary,
|
|
lifecycleOnly: false,
|
|
};
|
|
}
|
|
// A lifecycle frame for an already-semantic attempt carries a transport
|
|
// timestamp, not a new attempt timestamp. Keep the domain row's time as
|
|
// the ordering key so a late frame cannot move that attempt past newer
|
|
// semantic history.
|
|
if (attempt.lifecycleOnly && previous && !previous.lifecycleOnly) {
|
|
mergedAttempt.timestamp = previous.timestamp || mergedAttempt.timestamp;
|
|
}
|
|
mergedById.set(attempt.id, mergedAttempt);
|
|
if (incomingActive && previousTerminal && !replacesCompactedPlan) {
|
|
hasStaleActiveAttempt = true;
|
|
}
|
|
if (incomingActive && (!previous || replacesCompactedPlan)) introducedActiveAttempt = true;
|
|
}
|
|
if (incoming.surface === 'plan') {
|
|
// Plan task detail owns one canonical current fingerprint. An omitted
|
|
// prior unmatched attempt was replaced, not merely absent from a
|
|
// bounded history window; retain its reason but retire its liveness.
|
|
for (const [id, attempt] of mergedById) {
|
|
if (
|
|
!incomingIds.has(id)
|
|
&& (attempt.state === 'queued' || attempt.state === 'running')
|
|
) {
|
|
mergedById.set(id, {
|
|
...attempt, state: 'superseded', tone: 'neutral', superseded: true, progress: '',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const priorIds = prior.attempts.map((attempt) => attempt.id);
|
|
const priorOnly = priorIds.filter((id) => !incomingIds.has(id));
|
|
// A projection that contains every known attempt owns their order (for
|
|
// example, terminal Skill history arriving after one live row). A bounded
|
|
// projection that omits a known attempt keeps established order; a new
|
|
// timestamped identity is inserted before a later known timestamp.
|
|
let order;
|
|
if (priorOnly.length === 0) {
|
|
order = incoming.attempts.map((attempt) => attempt.id);
|
|
} else {
|
|
order = [...priorIds];
|
|
for (const attempt of incoming.attempts) {
|
|
if (priorById.has(attempt.id) || order.includes(attempt.id)) continue;
|
|
const timestamp = text(attempt.timestamp);
|
|
const insertBefore = timestamp
|
|
? order.findIndex((id) => {
|
|
const existing = text(mergedById.get(id)?.timestamp);
|
|
return existing && existing > timestamp;
|
|
})
|
|
: -1;
|
|
if (insertBefore >= 0) order.splice(insertBefore, 0, attempt.id);
|
|
else order.push(attempt.id);
|
|
}
|
|
}
|
|
const staleActiveRegression = (
|
|
(prior.state === 'terminal' || prior.state === 'superseded')
|
|
&& (incoming.state === 'queued' || incoming.state === 'running')
|
|
&& hasStaleActiveAttempt
|
|
&& !introducedActiveAttempt
|
|
);
|
|
const merged = {
|
|
...prior,
|
|
...incoming,
|
|
attempts: order.map((id) => mergedById.get(id)).filter(Boolean),
|
|
attemptCount: Math.max(prior.attemptCount || 0, incoming.attemptCount || 0, mergedById.size),
|
|
};
|
|
if (staleActiveRegression) {
|
|
merged.state = prior.state;
|
|
merged.verdict = prior.verdict;
|
|
merged.summary = prior.summary;
|
|
merged.activeCount = prior.activeCount;
|
|
merged.lifecycleStatus = prior.lifecycleStatus;
|
|
merged.tone = prior.progress ? prior.tone : reviewTone(merged.state, merged.verdict, merged.lifecycleStatus);
|
|
}
|
|
const activeAttempts = merged.attempts.filter(
|
|
(attempt) => attempt.state === 'queued' || attempt.state === 'running',
|
|
);
|
|
// A bounded/history projection can omit the newest live attempt. Preserve
|
|
// that attempt's liveness until a source explicitly updates the same
|
|
// immutable attempt id to a terminal state.
|
|
if (activeAttempts.length) {
|
|
const active = activeAttempts.at(-1);
|
|
merged.state = activeAttempts.some((attempt) => attempt.state === 'running') ? 'running' : 'queued';
|
|
merged.tone = (active.progress && active.tone) || 'working';
|
|
merged.verdict = active.verdict || (merged.lifecycleOnly ? '' : merged.state);
|
|
merged.summary = active.summary || merged.summary;
|
|
merged.activeCount = activeAttempts.length;
|
|
}
|
|
if (incoming.lifecycleOnly && incoming.attempts.length && !activeAttempts.length) {
|
|
// Project a lifecycle-only group from the latest merged attempt. A
|
|
// late frame for an existing attempt inherits that attempt's typed
|
|
// verdict; a genuinely new attempt stays neutral instead of borrowing
|
|
// the previous attempt's group-level PASS.
|
|
const latestAttempt = merged.attempts.at(-1);
|
|
if (latestAttempt) {
|
|
merged.state = latestAttempt.state;
|
|
merged.tone = reviewTone(
|
|
latestAttempt.state,
|
|
latestAttempt.verdict,
|
|
latestAttempt.lifecycleStatus,
|
|
);
|
|
merged.verdict = latestAttempt.verdict || '';
|
|
merged.summary = latestAttempt.summary || merged.summary;
|
|
merged.lifecycleOnly = Boolean(latestAttempt.lifecycleOnly);
|
|
}
|
|
}
|
|
const priorLatestAttempt = prior.attempts.at(-1);
|
|
const mergedLatestAttempt = merged.attempts.at(-1);
|
|
const newAttemptHasProvenablyNewerTimestamp = (
|
|
priorLatestAttempt?.timestamp
|
|
&& mergedLatestAttempt
|
|
&& !priorById.has(mergedLatestAttempt.id)
|
|
&& !mergedLatestAttempt.lifecycleOnly
|
|
&& hasSemanticVerdict(mergedLatestAttempt.verdict)
|
|
&& text(mergedLatestAttempt.timestamp) > text(priorLatestAttempt.timestamp)
|
|
);
|
|
const omittedLatestLifecycle = (
|
|
!activeAttempts.length
|
|
&& priorLatestAttempt?.lifecycleOnly
|
|
&& !incomingIds.has(priorLatestAttempt.id)
|
|
&& !newAttemptHasProvenablyNewerTimestamp
|
|
);
|
|
const delayedSemanticProjection = (
|
|
incoming.surface === 'skill'
|
|
&& priorOnly.length > 0
|
|
&& !activeAttempts.length
|
|
&& priorLatestAttempt
|
|
&& !priorLatestAttempt.lifecycleOnly
|
|
&& hasSemanticVerdict(priorLatestAttempt.verdict)
|
|
&& !incomingIds.has(priorLatestAttempt.id)
|
|
&& mergedLatestAttempt
|
|
&& !mergedLatestAttempt.lifecycleOnly
|
|
&& hasSemanticVerdict(mergedLatestAttempt.verdict)
|
|
&& (
|
|
!text(priorLatestAttempt.timestamp)
|
|
|| !text(mergedLatestAttempt.timestamp)
|
|
|| text(mergedLatestAttempt.timestamp) <= text(priorLatestAttempt.timestamp)
|
|
)
|
|
);
|
|
if (omittedLatestLifecycle || delayedSemanticProjection) {
|
|
// A stale refresh can omit the newest terminal lifecycle row or carry
|
|
// an older semantic row first seen after the newer one arrived live.
|
|
// Both keep the header on the newest proved attempt; an unresolved
|
|
// lifecycle still cannot borrow the older semantic verdict.
|
|
merged.state = priorLatestAttempt.state;
|
|
merged.tone = reviewTone(
|
|
priorLatestAttempt.state,
|
|
priorLatestAttempt.verdict,
|
|
priorLatestAttempt.lifecycleStatus,
|
|
);
|
|
merged.verdict = priorLatestAttempt.verdict || '';
|
|
merged.summary = priorLatestAttempt.summary || merged.summary;
|
|
merged.lifecycleOnly = Boolean(priorLatestAttempt.lifecycleOnly);
|
|
merged.lifecycleStatus = priorLatestAttempt.lifecycleStatus || merged.lifecycleStatus;
|
|
}
|
|
merged.initiatorTaskId = uniformAttemptInitiator(
|
|
merged.attempts,
|
|
incoming.initiatorTaskId || prior.initiatorTaskId,
|
|
);
|
|
// The progress phrase belongs to the attempt the header speaks for.
|
|
if ('progress' in merged) merged.progress = text((activeAttempts.at(-1) || merged.attempts.at(-1))?.progress);
|
|
if (JSON.stringify(prior) === JSON.stringify(merged)) return prior;
|
|
store.set(merged.id, merged);
|
|
return merged;
|
|
}
|
|
|
|
export function orderedReviewGroups(store) {
|
|
const groups = store instanceof Map ? [...store.values()] : Array.isArray(store) ? [...store] : [];
|
|
return groups.sort((a, b) => (SURFACE_ORDER.get(a.surface) ?? 99) - (SURFACE_ORDER.get(b.surface) ?? 99));
|
|
}
|
|
|
|
export function reviewGroupCounts(store) {
|
|
const groups = orderedReviewGroups(store);
|
|
return {
|
|
groupCount: groups.length,
|
|
activeCount: groups.reduce((total, group) => total + (finiteCount(group.activeCount) || 0), 0),
|
|
};
|
|
}
|
|
|
|
function reviewRevision(value) {
|
|
const revision = text(value).toLowerCase();
|
|
return /^[0-9a-f]{64}$/.test(revision) ? revision : null;
|
|
}
|
|
|
|
// Revisions are opaque SHA-256 tokens; one distinct token may trail a live GET.
|
|
export function createReviewHydrator({ fetchDetail, applyDetail, onState = () => {} } = {}) {
|
|
const states = new Map();
|
|
|
|
const start = (taskId, state, revision, onDomWrite) => {
|
|
const generation = ++state.generation;
|
|
state.inFlightRevision = revision;
|
|
const write = typeof onDomWrite === 'function' ? onDomWrite : (mutate) => mutate();
|
|
// First load/retry announces; routine refresh over applied content stays quiet.
|
|
const notify = (status) => {
|
|
state.lastStatus = status;
|
|
return write(() => onState(taskId, status));
|
|
};
|
|
const request = Promise.resolve()
|
|
.then(() => {
|
|
if (!state.everApplied || state.lastStatus === 'error') notify('loading');
|
|
return fetchDetail(taskId);
|
|
})
|
|
.then((detail) => {
|
|
// The strict seam rejects failures; null means genuinely absent (404).
|
|
if (detail === null || detail === undefined) return false;
|
|
return write(() => applyDetail(taskId, detail));
|
|
})
|
|
.then((applied) => {
|
|
if (applied !== false && revision !== null) state.appliedRevision = revision;
|
|
state.everApplied = true;
|
|
notify('idle');
|
|
return applied;
|
|
})
|
|
.catch(() => {
|
|
notify('error');
|
|
return false;
|
|
})
|
|
.finally(() => {
|
|
if (state.inFlight !== request || state.generation !== generation) return;
|
|
state.inFlight = null;
|
|
state.inFlightRevision = null;
|
|
const pending = state.pending;
|
|
state.pending = null;
|
|
if (pending && pending.revision !== state.appliedRevision) {
|
|
start(taskId, state, pending.revision, pending.onDomWrite);
|
|
}
|
|
});
|
|
state.inFlight = request;
|
|
return request;
|
|
};
|
|
|
|
return {
|
|
hydrate(taskIdValue, revisionValue = null, { onDomWrite = null } = {}) {
|
|
const taskId = text(taskIdValue);
|
|
if (!taskId) return Promise.resolve(false);
|
|
const revision = reviewRevision(revisionValue);
|
|
let state = states.get(taskId);
|
|
if (!state) {
|
|
state = {
|
|
appliedRevision: null,
|
|
inFlightRevision: null,
|
|
pending: null,
|
|
inFlight: null,
|
|
generation: 0,
|
|
everApplied: false,
|
|
lastStatus: 'idle',
|
|
};
|
|
states.set(taskId, state);
|
|
}
|
|
if (revision !== null && revision === state.appliedRevision) {
|
|
return Promise.resolve(false);
|
|
}
|
|
if (state.inFlight) {
|
|
if (revision !== null && revision === state.pending?.revision) {
|
|
return state.inFlight.then(() => state.inFlight || false);
|
|
}
|
|
if (
|
|
revision !== null
|
|
&& revision !== state.inFlightRevision
|
|
) {
|
|
state.pending = { revision, onDomWrite };
|
|
return state.inFlight.then(() => state.inFlight || false);
|
|
}
|
|
return state.inFlight;
|
|
}
|
|
return start(taskId, state, revision, onDomWrite);
|
|
},
|
|
invalidateApplied(taskIdValue = '') {
|
|
const taskId = text(taskIdValue);
|
|
if (taskId) {
|
|
const state = states.get(taskId);
|
|
if (state) state.appliedRevision = null;
|
|
return;
|
|
}
|
|
// A full DOM rebuild discards the projection that an applied
|
|
// revision hydrated, but it must retain and join any physical GET
|
|
// already in flight. Reset only the applied presentation receipt.
|
|
for (const state of states.values()) state.appliedRevision = null;
|
|
},
|
|
clear() {
|
|
states.clear();
|
|
},
|
|
};
|
|
}
|
|
|
|
function attemptMeta(attempt) {
|
|
return [
|
|
attempt.timestamp,
|
|
attempt.progress || attempt.verdict || (attempt.lifecycleOnly
|
|
? (['queued', 'running'].includes(attempt.state) ? attempt.state : 'review verdict unavailable')
|
|
: ''),
|
|
lifecycleMeta(attempt.lifecycleStatus),
|
|
attempt.superseded ? 'superseded' : '',
|
|
attempt.replayed ? 'replay' : '',
|
|
attempt.revised ? 'revised snapshot' : '',
|
|
].filter(Boolean).join(' · ');
|
|
}
|
|
|
|
/**
|
|
* Return only an explicit executed receipt. Requested/effective route intent
|
|
* is deliberately ignored: identity markup must not manufacture execution.
|
|
*/
|
|
export function reviewExecutionEvidence(execution) {
|
|
const wrapped = execution?.executed && typeof execution.executed === 'object';
|
|
const executed = wrapped
|
|
? execution.executed
|
|
: execution;
|
|
if (!executed || typeof executed !== 'object') return null;
|
|
const kind = text(executed.kind || executed.route_kind || executed.channel).toLowerCase();
|
|
const harness = text(executed.harness_id || executed.harness);
|
|
const api = ['api', 'api_chat', 'api_model', 'native'].includes(kind);
|
|
const harnessReceipt = kind === 'harness' || kind === 'agent_session' || (wrapped && Boolean(harness));
|
|
if (!api && (!harnessReceipt || !harness)) return null;
|
|
return {
|
|
harness: api ? 'api' : harness,
|
|
channel: api ? 'api' : '',
|
|
label: kind === 'native' ? 'API · native tool rounds' : text(executed.label),
|
|
model: text(executed.model || executed.model_id),
|
|
};
|
|
}
|
|
|
|
export function reviewExecutionEvidenceList(executions, legacyExecution = null) {
|
|
const rows = normalizedExecutions(executions, legacyExecution);
|
|
const seen = new Set();
|
|
const evidence = [];
|
|
for (const row of rows) {
|
|
const normalized = reviewExecutionEvidence(row);
|
|
if (!normalized) continue;
|
|
const key = [normalized.harness, normalized.channel, normalized.label, normalized.model].join('\u0000');
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
evidence.push(normalized);
|
|
}
|
|
return evidence;
|
|
}
|
|
|
|
export function reviewReferenceFromRow(row) {
|
|
const directType = text(row?.system_type || row?.type || row?.event);
|
|
const direct = directType === 'review_reference' ? row : null;
|
|
const nested = row?.review_reference && typeof row.review_reference === 'object'
|
|
? row.review_reference
|
|
: (row?.progress_meta?.review_reference && typeof row.progress_meta.review_reference === 'object'
|
|
? row.progress_meta.review_reference
|
|
: null);
|
|
const reference = direct || nested;
|
|
if (!reference || !['plan_review', 'task_acceptance'].includes(text(reference.surface))) return null;
|
|
const presentationOwnerTaskId = text(
|
|
reference.presentation_owner_task_id || reference.task_id
|
|
|| row?.presentation_owner_task_id || row?.task_id,
|
|
);
|
|
if (!presentationOwnerTaskId) return null;
|
|
return {
|
|
surface: text(reference.surface),
|
|
presentationOwnerTaskId,
|
|
stateRevision: text(reference.state_revision),
|
|
reviewFingerprint: text(reference.review_fingerprint),
|
|
};
|
|
}
|
|
|
|
export function renderReviewsSection(groupsInput, disclosure = {}) {
|
|
const groups = orderedReviewGroups(groupsInput);
|
|
// A failed FIRST hydration has no groups to hang the error on: the shell
|
|
// renders anyway and stays mounted through the retry's own loading pass
|
|
// (hadHydrateError) so the recovery control cannot unmount mid-flight. A
|
|
// quiet first-load zero-group loading pass stays invisible (every card
|
|
// expand hydrates, most tasks have no reviews).
|
|
const hydrateStatus = text(disclosure.hydrateStatus);
|
|
const emptyShell = !groups.length && (
|
|
hydrateStatus === 'error'
|
|
|| (hydrateStatus === 'loading' && disclosure.hadHydrateError === true)
|
|
);
|
|
if (!groups.length && !emptyShell) return '';
|
|
const expandedGroups = disclosure.expandedGroups instanceof Set ? disclosure.expandedGroups : new Set();
|
|
const expandedAttempts = disclosure.expandedAttempts instanceof Set ? disclosure.expandedAttempts : new Set();
|
|
const sectionExpanded = disclosure.sectionExpanded === true;
|
|
const { groupCount, activeCount } = reviewGroupCounts(groups);
|
|
const countText = groupCount
|
|
? `${groupCount}${activeCount ? ` · ${activeCount} active` : ''}`
|
|
: '—';
|
|
const groupHtml = groups.map((group) => {
|
|
const groupExpanded = expandedGroups.has(group.id);
|
|
const shown = group.countIsAuthoritative ? `${group.attemptCount}` : `${group.attempts.length} shown`;
|
|
const attempts = group.attempts.map((attempt) => {
|
|
const attemptKey = `${group.id}:${attempt.id}`;
|
|
const attemptExpanded = expandedAttempts.has(attemptKey);
|
|
const skillRef = attempt.detailRef?.surface === 'skill' ? attempt.detailRef : null;
|
|
const detailAttrs = skillRef
|
|
? ` data-skill-review-skill="${escapeHtmlAttr(skillRef.skill)}" data-skill-review-job="${escapeHtmlAttr(skillRef.jobId)}"`
|
|
: '';
|
|
const source = attempt.detailRef?.surface === 'task_acceptance' ? attempt.detailRef : null;
|
|
const fullSource = source ? (source.url
|
|
? `<a class="btn btn-default" href="${escapeHtmlAttr(source.url)}" download>Download full applied review</a>`
|
|
: '<span>Full applied review unavailable.</span>') : '';
|
|
const detail = skillRef ? '' : `<span>${escapeHtmlText(attempt.detailText || attempt.summary || 'No additional detail projected.')}</span>${fullSource ? `<div>${fullSource}</div>` : ''}`;
|
|
const executions = reviewExecutionEvidenceList(attempt.executions, attempt.execution);
|
|
const executionMarkup = executions.map((execution) => (
|
|
harnessIdentityMarkup(execution.harness, {
|
|
channel: execution.channel,
|
|
label: execution.label,
|
|
className: 'chat-review-execution-identity',
|
|
}) + (execution.model
|
|
? `<span class="chat-review-execution-model">${escapeHtmlText(execution.model)}</span>`
|
|
: '')
|
|
)).join('');
|
|
const attemptInitiator = (
|
|
attempt.initiatorTaskId
|
|
&& attempt.initiatorTaskId !== group.presentationOwnerTaskId
|
|
&& attempt.initiatorTaskId !== group.initiatorTaskId
|
|
)
|
|
? `<div class="chat-review-initiator">Initiated by task ${escapeHtmlText(attempt.initiatorTaskId)}</div>`
|
|
: '';
|
|
return `
|
|
<div class="chat-review-attempt ${escapeHtmlAttr(attempt.tone)}${attempt.superseded ? ' superseded' : ''}" data-review-attempt="${escapeHtmlAttr(attemptKey)}">
|
|
<div class="chat-review-attempt-main">
|
|
<span class="chat-review-attempt-label">${escapeHtmlText(attempt.label)}</span>
|
|
<span class="chat-review-attempt-meta">${escapeHtmlText(attemptMeta(attempt))}</span>
|
|
${executionMarkup ? `<span class="chat-review-execution">${executionMarkup}</span>` : ''}
|
|
</div>
|
|
${attemptInitiator}
|
|
<button type="button" class="chat-review-detail-toggle" data-review-attempt-toggle="${escapeHtmlAttr(attemptKey)}" aria-expanded="${attemptExpanded ? 'true' : 'false'}">${attemptExpanded ? 'Hide details' : 'Show details'}</button>
|
|
<div class="chat-review-attempt-detail" data-review-attempt-detail="${escapeHtmlAttr(attemptKey)}"${detailAttrs} aria-busy="false"${attemptExpanded ? '' : ' hidden'}>${detail}</div>
|
|
</div>`;
|
|
}).join('');
|
|
const initiator = group.initiatorTaskId && group.initiatorTaskId !== group.presentationOwnerTaskId
|
|
? `<div class="chat-review-initiator">Initiated by task ${escapeHtmlText(group.initiatorTaskId)}</div>`
|
|
: '';
|
|
return `
|
|
<div class="chat-review-group ${escapeHtmlAttr(group.tone)}" data-review-group="${escapeHtmlAttr(group.id)}">
|
|
<button type="button" class="chat-review-group-toggle" data-review-group-toggle="${escapeHtmlAttr(group.id)}" aria-expanded="${groupExpanded ? 'true' : 'false'}">
|
|
<span class="chat-review-group-main">
|
|
<span class="chat-review-group-label">${escapeHtmlText(group.label)}</span>
|
|
${group.subject ? `<span class="chat-review-subject">${escapeHtmlText(group.subject)}</span>` : ''}
|
|
</span>
|
|
<span class="chat-review-group-meta">${escapeHtmlText([group.progress || group.verdict || (group.lifecycleOnly
|
|
? (['queued', 'running'].includes(group.state) ? group.state : 'review verdict unavailable')
|
|
: group.state), lifecycleMeta(group.lifecycleStatus), shown].filter(Boolean).join(' · '))}</span>
|
|
</button>
|
|
<div class="chat-review-attempts"${groupExpanded ? '' : ' hidden'}>
|
|
<div class="chat-review-group-cost">Cost unavailable</div>
|
|
${group.authorDecisionText ? `<div class="chat-review-attempt-detail" data-review-author-decision><span>${group.surface === 'plan' ? 'Plan' : 'Task'} author decision</span><br><span>${escapeHtmlText(group.authorDecisionText)}</span></div>` : ''}
|
|
${initiator}${attempts}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
// Section-level hydration truth (typed controller state, no per-attempt
|
|
// FSM): a first load announces itself, a failed refresh names itself and
|
|
// offers Retry instead of silently presenting stale-or-missing detail.
|
|
// The message rides inside a <span>: the keyed status node is patched in
|
|
// place across loading↔error, and the DOM patcher syncs text only through
|
|
// childless-element innerHTML — a bare text node beside the Retry button
|
|
// would survive the transition stale.
|
|
const hydrateHtml = hydrateStatus === 'loading'
|
|
? '<div class="skill-review-loading" data-review-hydrate-status role="status" aria-live="polite"><span>Loading review details…</span></div>'
|
|
: (hydrateStatus === 'error'
|
|
? '<div class="skill-review-error" data-review-hydrate-status role="alert"><span>Review details failed to refresh — shown data may be incomplete. </span><button type="button" class="skill-review-retry" data-review-hydrate-retry>Retry</button></div>'
|
|
: '');
|
|
// The status node sits OUTSIDE the collapsible groups container: a failed
|
|
// refresh stays visible on a collapsed section and the keyed node survives
|
|
// loading↔error transitions. Disclosure stays user-owned — nothing expands.
|
|
return `
|
|
<section class="chat-live-reviews" data-review-section data-expanded="${sectionExpanded ? '1' : '0'}">
|
|
<button type="button" class="chat-review-section-toggle" data-review-section-toggle aria-expanded="${sectionExpanded ? 'true' : 'false'}">
|
|
<span>Reviews</span><span class="chat-review-section-count">${escapeHtmlText(countText)}</span>
|
|
</button>
|
|
${hydrateHtml}<div class="chat-review-groups"${sectionExpanded ? '' : ' hidden'}>${groupHtml}</div>
|
|
</section>`;
|
|
}
|
|
|
|
/**
|
|
* Interactive Reviews renderer. Chat owns disclosure and hydration callbacks;
|
|
* review events update content without taking disclosure ownership.
|
|
*/
|
|
export function createReviewPresentationController({
|
|
host,
|
|
summary,
|
|
disclosure,
|
|
onHydrate = () => {},
|
|
onLoadSkillDetail = () => {},
|
|
onDomWrite = (mutate) => mutate(),
|
|
} = {}) {
|
|
const groups = new Map();
|
|
const state = disclosure || {};
|
|
if (!(state.expandedGroups instanceof Set)) state.expandedGroups = new Set();
|
|
if (!(state.expandedAttempts instanceof Set)) state.expandedAttempts = new Set();
|
|
if (state.sectionExpanded !== true) state.sectionExpanded = false;
|
|
|
|
const focusedControl = () => {
|
|
const active = host?.ownerDocument?.activeElement;
|
|
if (!active || !host?.contains?.(active)) return null;
|
|
if (active.matches?.('[data-review-section-toggle]')) return { kind: 'section', key: '' };
|
|
if (active.dataset?.reviewGroupToggle) return { kind: 'group', key: active.dataset.reviewGroupToggle };
|
|
if (active.dataset?.reviewAttemptToggle) return { kind: 'attempt', key: active.dataset.reviewAttemptToggle };
|
|
const detail = active.closest?.('[data-review-attempt-detail]');
|
|
if (detail?.dataset?.reviewAttemptDetail) {
|
|
return { kind: 'attempt', key: detail.dataset.reviewAttemptDetail };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const restoreFocus = (focused) => {
|
|
if (!focused) return;
|
|
let target = null;
|
|
if (focused.kind === 'section') target = host.querySelector?.('[data-review-section-toggle]');
|
|
const attribute = focused.kind === 'group'
|
|
? 'reviewGroupToggle'
|
|
: (focused.kind === 'attempt' ? 'reviewAttemptToggle' : '');
|
|
if (attribute) {
|
|
target = Array.from(host.querySelectorAll?.(`[data-${focused.kind === 'group' ? 'review-group-toggle' : 'review-attempt-toggle'}]`) || [])
|
|
.find((candidate) => candidate.dataset?.[attribute] === focused.key);
|
|
}
|
|
target?.focus?.();
|
|
};
|
|
|
|
const render = () => onDomWrite(() => {
|
|
if (!host || !summary) return;
|
|
const focused = focusedControl();
|
|
const { groupCount, activeCount } = reviewGroupCounts(groups);
|
|
const failedEmpty = groupCount === 0 && (
|
|
state.hydrateStatus === 'error'
|
|
|| (state.hydrateStatus === 'loading' && state.hadHydrateError === true)
|
|
);
|
|
summary.hidden = groupCount === 0 && !failedEmpty;
|
|
summary.textContent = groupCount
|
|
? `Reviews ${groupCount}${activeCount ? ` · ${activeCount} active` : ''}`
|
|
: (failedEmpty ? 'Reviews' : '');
|
|
const warnings = [...groups.values()].map(group => text(group.warning)).filter(Boolean);
|
|
summary.dataset.warning = warnings.length ? '1' : '0';
|
|
if (warnings.length) summary.textContent += ' · Acceptance unavailable: evidence preparation failed; no new reviewers.';
|
|
const reconciled = reconcileReviewMarkup(host, renderReviewsSection(groups, state));
|
|
const active = host?.ownerDocument?.activeElement;
|
|
if (!reconciled || !active || !host.contains?.(active)) restoreFocus(focused);
|
|
for (const detail of host.querySelectorAll?.('[data-review-attempt-detail]') || []) {
|
|
if (
|
|
!detail?.hidden
|
|
&& detail.dataset?.skillReviewSkill
|
|
&& detail.dataset?.skillReviewJob
|
|
) onLoadSkillDetail(detail);
|
|
}
|
|
});
|
|
|
|
host?.addEventListener('click', (event) => {
|
|
const hydrateRetry = event.target?.closest?.('[data-review-hydrate-retry]');
|
|
if (hydrateRetry) {
|
|
// A failed pass never records an applied revision, so a plain
|
|
// revision-less re-hydrate always re-issues the physical GET.
|
|
onHydrate();
|
|
const status = host.querySelector?.('[data-review-hydrate-status]');
|
|
if (status) {
|
|
status.setAttribute?.('tabindex', '-1');
|
|
status.focus?.();
|
|
}
|
|
return;
|
|
}
|
|
const retry = event.target?.closest?.('[data-skill-review-retry]');
|
|
if (retry) {
|
|
const detail = retry.closest?.('[data-review-attempt-detail]');
|
|
if (detail?.dataset?.skillReviewSkill && detail?.dataset?.skillReviewJob) {
|
|
onLoadSkillDetail(detail, { retry: true });
|
|
detail.setAttribute?.('tabindex', '-1');
|
|
detail.focus?.();
|
|
}
|
|
return;
|
|
}
|
|
const sectionToggle = event.target?.closest?.('[data-review-section-toggle]');
|
|
if (sectionToggle) {
|
|
state.sectionExpanded = !state.sectionExpanded;
|
|
render();
|
|
if (state.sectionExpanded) onHydrate();
|
|
return;
|
|
}
|
|
const groupToggle = event.target?.closest?.('[data-review-group-toggle]');
|
|
if (groupToggle) {
|
|
const groupId = groupToggle.dataset.reviewGroupToggle || '';
|
|
if (!groupId) return;
|
|
if (state.expandedGroups.has(groupId)) state.expandedGroups.delete(groupId);
|
|
else state.expandedGroups.add(groupId);
|
|
render();
|
|
if (state.expandedGroups.has(groupId)) onHydrate();
|
|
return;
|
|
}
|
|
const attemptToggle = event.target?.closest?.('[data-review-attempt-toggle]');
|
|
if (!attemptToggle) return;
|
|
const attemptKey = attemptToggle.dataset.reviewAttemptToggle || '';
|
|
if (!attemptKey) return;
|
|
const opening = !state.expandedAttempts.has(attemptKey);
|
|
if (opening) state.expandedAttempts.add(attemptKey);
|
|
else state.expandedAttempts.delete(attemptKey);
|
|
render();
|
|
});
|
|
|
|
return {
|
|
groups,
|
|
render,
|
|
update(group) {
|
|
const prior = groups.get(group?.id), merged = mergeReviewGroup(groups, group);
|
|
if (merged && merged !== prior) render();
|
|
return merged === prior ? false : merged;
|
|
},
|
|
updateMany(nextGroups) {
|
|
let changed = false;
|
|
for (const group of Array.isArray(nextGroups) ? nextGroups : []) {
|
|
const prior = groups.get(group?.id), merged = mergeReviewGroup(groups, group);
|
|
if (merged && merged !== prior) changed = true;
|
|
}
|
|
if (changed) render();
|
|
return changed;
|
|
},
|
|
setHydrateStatus(statusValue) {
|
|
const statusVisible = (value) => groups.size > 0 || value === 'error'
|
|
|| (value === 'loading' && state.hadHydrateError === true);
|
|
const status = text(statusValue);
|
|
if (state.hydrateStatus === status) return false;
|
|
const wasVisible = statusVisible(state.hydrateStatus);
|
|
// Remember a failure across the retry's loading pass so the
|
|
// zero-group shell stays mounted until the retry settles.
|
|
if (status === 'error') state.hadHydrateError = true;
|
|
else if (status === 'idle') state.hadHydrateError = false;
|
|
state.hydrateStatus = status;
|
|
const isVisible = statusVisible(status);
|
|
if (wasVisible || isVisible) render();
|
|
return wasVisible || isVisible;
|
|
},
|
|
};
|
|
}
|