feat(rectification): 区间常驻条补代表分钟与已答题数,盘面五段折叠收成两段
Independent Staging Quality Gate / validate (push) Canceled after 3m14s
Independent Staging Quality Gate / publish (push) Canceled after 0s

R7 / 决策 D10。任务书 docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md。

T7.1 常驻条。任务书 E9 说区间只在盘面标题行,实际读代码:对话区顶部
已有常驻条 RectificationTimeline 且已显示区间与宽度,窄屏 .is-compact 下
也可见。真正只存在于 .rectification-board__clock 的是代表分钟——窄屏盘面
是默认关闭的 overlay,代表分钟就此看不见。所以没有另造第二条(会撞固定条
高这条正确性约束),而是在既有条上补齐缺的两项:

  05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题

代表分钟与盘面标题行是同一次 workingRectificationTime(result) 调用,一个
值两处用,不是两条推导路径。已答题数逐字读服务端的
inference_state.answered_probe_count,投影没给就整项不显示,不补「已答 0 题」。
条上不写确认,边界句仍只在交付/采用旁白里出现一次。读数只增宽不增高:条高
在滚动容器外,变高会让贴底读者的内容无声滑出视野。

收窄进度未做,触发让步顺序第 5 条。没有这个字段(服务端只把这句话拼进旁白
正文),且前端自己减会算错——candidate_range 是当前窗口且会放宽(BUG-572),
拿它当「最初」相减会把一次放宽报成一次收窄。缺口与解除条件写进 BLOCKED.md。

T7.2 盘面折叠。实际有 8 个 details(任务书记的五处漏了交界节奏、错标了两块),
按内容归成「候选分钟」(换升时刻 + 次级候选)与「参数与口径」(宫位表、确认门、
本轮技法、两段本命上升、经历与大运对照含双轨一致性、交界节奏)。每块保留原有
渲染条件,engineMeaningToDisplayCopy() 仍是 5 处,逐分钟行的 is-changed /
当前时间 一字未改。共享组件 technique-audit-disclosure.tsx 未动。

顺带修掉一个静默失效的断言:rectification-agentic-entry 用
board.slice(indexOf(A), indexOf(B)),B 改名后 indexOf 返回 -1,slice(i, -1)
会悄悄退化成几乎整份文件,底下四条 doesNotMatch 照样绿。已换成带越界断言的
sliceBetween()。两处改动的断言都在测试里写了原值/新值/原因。

tsc 0 错;lint 0 error / 118 warning(同基线);npm test 3350 → 3360,失败
仍是 31 条无 Docker 用例且与基线逐条一致;next build exit 0,/ 仍 ○ Static;
产物 CSS gzip 39,017 → 38,973 B(−0.11%)。

窄屏 .is-compact 下常驻条的实际可见性无 Chrome 无法验证,已按任务书写成环境
缺口,未标通过;CSS 上已确认无任何规则在该状态下隐藏该条,并写成测试断言。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 06:44:49 +00:00
co-authored by Claude Opus 5
parent a1a78c7ebc
commit ca0204984b
13 changed files with 751 additions and 72 deletions
@@ -105,6 +105,12 @@ export type RectificationCandidateResult = Readonly<{
verificationReportMarkdown: string | null;
/** Inference-layer minutes including eliminated ones. Empty when no inference_state. */
inferenceMarks?: readonly RectificationInferenceMark[];
/**
* `inference_state.answered_probe_count`, read verbatim off the wire. The
* count is the server's own; nothing here recounts answers or derives one
* from the transcript (VOICE.md rule 2: numbers come from the projection).
*/
answeredProbeCount?: number | null;
}>;
function record(value: unknown): Record<string, unknown> | null {
@@ -294,6 +300,17 @@ function parseCredibleRange(value: unknown): readonly [string, string] | null {
return [start, end];
}
/**
* The server's count of answered discrimination probes. Read, never computed:
* the bar above the conversation may only show numbers the projection already
* carries, so this returns null rather than counting anything locally.
*/
function parseAnsweredProbeCount(receipt: Record<string, unknown> | null): number | null {
const state = record(receipt?.inference_state);
const value = state?.answered_probe_count;
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
function parseInferenceMarks(receipt: Record<string, unknown> | null): readonly RectificationInferenceMark[] {
const state = record(receipt?.inference_state);
if (!state || !Array.isArray(state.candidates)) return [];
@@ -411,6 +428,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
(snapshot.rangeDelivery ?? snapshot.range_delivery) as { verification_markdown?: unknown } | undefined,
),
inferenceMarks: parseInferenceMarks(receipt),
answeredProbeCount: parseAnsweredProbeCount(receipt),
};
}
@@ -15,6 +15,14 @@
* minute-level discriminating power, so a graded mark would render a
* difference the engine cannot support. The only encoding is in-range or
* excluded.
*
* There is deliberately no "narrowed from N minutes to M" readout. The opening
* window is on the wire, but no projection carries that comparison as a field,
* and VOICE.md rule 2 puts progress figures on the server. Computing it here
* would also be wrong as often as right: the axis is the *current* window and
* it widens (BUG-572), so the arithmetic would report progress through a
* widening. The narration keeps saying it, composed server-side where the case
* history is. See docs/tasks/PROGRESS-cend-rectification-20260916.md.
*/
const MINUTES_PER_DAY = 1440;
@@ -47,6 +55,20 @@ export type RectificationTimelineView = Readonly<{
rangeLabel: string;
/** `3 分钟` */
widthLabel: string;
/**
* `代表分钟 05:08`, or `已采用 04:53` once a minute has been adopted. Null
* when the case has no working minute yet.
*
* Adopted is not confirmed: the label states which minute is in use and makes
* no claim about the birth minute being settled. The full boundary sentence
* stays where it belongs, in the delivery/adopt narration.
*/
workingLabel: string | null;
/**
* `已答 6 题`, straight off `inference_state.answered_probe_count`. Null when
* the projection carries no count — never a zero this module invented.
*/
answeredLabel: string | null;
}>;
/** `HH:MM` or `HH:MM:SS` to minutes past midnight; null when unparseable. */
@@ -149,6 +171,16 @@ export type RectificationTimelineInput = Readonly<{
*/
inferenceMarks?: readonly TimelineInferenceMark[];
stage: RectificationTimelineStage | null;
/**
* `workingRectificationTime(result)` — the very call the board's title-row
* clock makes. Passing the resolved minute rather than the result keeps one
* source for both surfaces, so the bar and the board can never disagree.
*/
workingTime?: string | null;
/** True once the reader adopted this minute, which changes only the label. */
workingAdopted?: boolean;
/** `inference_state.answered_probe_count`; absent when not projected. */
answeredProbeCount?: number | null;
}>;
/**
@@ -218,5 +250,31 @@ export function buildRectificationTimeline(
ticks: buildTicks(axisStart, axisEnd),
rangeLabel: `${formatClockMinutes(bandStart)}–${formatClockMinutes(bandEnd)}`,
widthLabel: timelineDurationLabel(bandEnd - bandStart + 1),
workingLabel: workingMinuteLabel(input.workingTime, input.workingAdopted === true),
answeredLabel: answeredProbeLabel(input.answeredProbeCount),
};
}
/**
* The working minute as the bar says it. The clock string is echoed, not
* recomputed: an unparseable value yields no label rather than a guess.
*/
export function workingMinuteLabel(
workingTime: string | null | undefined,
adopted: boolean,
): string | null {
const minutes = parseClockMinutes(workingTime);
if (minutes === null) return null;
const clock = formatClockMinutes(minutes);
return adopted ? `已采用 ${clock}` : `代表分钟 ${clock}`;
}
/**
* Renders the server's answered-probe count and nothing else. A missing count
* stays missing: showing `已答 0 题` where the projection said nothing would be
* a number this layer made up.
*/
export function answeredProbeLabel(count: number | null | undefined): string | null {
if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return null;
return `已答 ${count} 题`;
}