@xzzpig/pi-openspec-x 0.1.0

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.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The enforced "review before tick" gate for agent mode (task 8.2; spec MUST
3
+ * "未审查不得勾选").
4
+ *
5
+ * `worker-dispatch.ts` decides *whether* a tick is allowed; this module is what
6
+ * makes the decision binding. It tracks the main session's dispatch traffic —
7
+ * an `opsx-worker` dispatch, the `report_work` review that must follow it, and
8
+ * the tick — and the `tool_call` handler uses it to block an
9
+ * `update_goal_task` completion that has no review behind it.
10
+ *
11
+ * The gate is agent-only on purpose: direct mode has no dispatch, so there is
12
+ * nothing to reconcile and the completion audit remains the only gate.
13
+ */
14
+ import {
15
+ decideTaskTick,
16
+ detectStalledDispatch,
17
+ type DispatchTrace,
18
+ type WorkReportLike,
19
+ } from "./worker-dispatch.ts";
20
+
21
+ /** One observed worker dispatch and what happened to it. */
22
+ interface DispatchRecord {
23
+ /** The task the dispatch was for, when it could be read from the payload. */
24
+ taskId: string | undefined;
25
+ changedFiles: number;
26
+ ticked: boolean;
27
+ }
28
+
29
+ /**
30
+ * Explicit failure markers in a recorded verification result.
31
+ *
32
+ * Kept as two expressions because the uppercase markers must stay
33
+ * case-sensitive: a case-insensitive `FAIL` would match the "0 failures" of a
34
+ * clean run, and a false hold is worse than a missed one here (the completion
35
+ * audit is the real verification gate). The accepted cost is that a lowercase
36
+ * marker such as "2 errors" is not detected — only `FAIL`/`FAILED`/`ERROR` in
37
+ * caps, a non-zero exit code, or an explicit "<n> failures" count are.
38
+ */
39
+ const UPPERCASE_FAILURE_RE = /(^|\s)(FAIL|FAILED|ERROR|✗)/;
40
+ const NUMERIC_FAILURE_RE =
41
+ /\bexit(?:ed)? (?:code )?[1-9]\b|\b[1-9]\d* (?:tests? )?fail/i;
42
+
43
+ function looksFailed(result: string | undefined): boolean {
44
+ if (result === undefined) return false;
45
+ return UPPERCASE_FAILURE_RE.test(result) || NUMERIC_FAILURE_RE.test(result);
46
+ }
47
+
48
+ /**
49
+ * Per-session dispatch bookkeeping. One tracker per active agent flow.
50
+ */
51
+ export class DispatchReviewTracker {
52
+ private readonly records: DispatchRecord[] = [];
53
+ private readonly reports = new Map<string, WorkReportLike>();
54
+
55
+ /**
56
+ * Record an `opsx-worker` dispatch. `taskId` is undefined when the payload
57
+ * did not name the task (the `subagent` tool accepts several shapes), which
58
+ * still counts as "a dispatch happened" — the gate must never deadlock on a
59
+ * task-id parsing miss.
60
+ */
61
+ noteDispatch(taskId: string | undefined): void {
62
+ this.records.push({
63
+ taskId,
64
+ changedFiles: 0,
65
+ ticked: false,
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Remember the main agent's `report_work` review and attach its changed-file
71
+ * count to the most recent dispatch it can belong to.
72
+ */
73
+ rememberReport(report: WorkReportLike): void {
74
+ this.reports.set(report.taskId, report);
75
+ for (let index = this.records.length - 1; index >= 0; index -= 1) {
76
+ const record = this.records[index]!;
77
+ if (record.taskId !== undefined && record.taskId !== report.taskId) {
78
+ continue;
79
+ }
80
+ record.taskId = report.taskId;
81
+ record.changedFiles = report.changedFiles.length;
82
+ break;
83
+ }
84
+ }
85
+
86
+ /** Record that a task was ticked. */
87
+ noteTick(taskId: string): void {
88
+ for (const record of this.records) {
89
+ if (record.taskId === taskId) record.ticked = true;
90
+ }
91
+ }
92
+
93
+ /** The observed `report_work` for a task, if the main agent submitted one. */
94
+ reportFor(taskId: string): WorkReportLike | undefined {
95
+ return this.reports.get(taskId);
96
+ }
97
+
98
+ /**
99
+ * Whether a worker dispatch was observed for the task. A dispatch whose
100
+ * payload never named a task counts for every task: the alternative is
101
+ * blocking a legitimate tick because of an unparsed payload shape.
102
+ */
103
+ dispatched(taskId: string): boolean {
104
+ return this.records.some(
105
+ (record) => record.taskId === taskId || record.taskId === undefined,
106
+ );
107
+ }
108
+
109
+ /** The bounded dispatch trace used for stall detection. */
110
+ traces(): DispatchTrace[] {
111
+ return this.records.map((record) => ({
112
+ taskId: record.taskId ?? "<unknown>",
113
+ changedFiles: record.changedFiles,
114
+ ticked: record.ticked,
115
+ }));
116
+ }
117
+
118
+ /** Tasks that keep being dispatched without producing or reporting work. */
119
+ stalled(threshold = 2): string[] {
120
+ const result = detectStalledDispatch(this.traces(), threshold);
121
+ return result.stalled ? result.taskIds : [];
122
+ }
123
+
124
+ reset(): void {
125
+ this.records.length = 0;
126
+ this.reports.clear();
127
+ }
128
+ }
129
+
130
+ type TickGateDecision =
131
+ | { allow: true; warnings: string[] }
132
+ | { allow: false; reason: string };
133
+
134
+ /**
135
+ * Root paths that belong to tooling rather than to a task's output. A window
136
+ * delta picks these up because extensions write into the working directory
137
+ * (pi-lens probe logs), and they are not the dispatch's work. Build-output
138
+ * directories are deliberately NOT listed: `dist/`, `coverage/`,
139
+ * `node_modules/` and `.cache/` are exactly what the agent write allowlist
140
+ * permits, so a task whose output lives there must stay reconcilable.
141
+ */
142
+ const RUNTIME_ROOT_PREFIXES = [".pi/", ".pi-lens-probe-home/"];
143
+
144
+ function normalizePath(filePath: string): string {
145
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
146
+ }
147
+
148
+ /** Drop tool-runtime noise from an observed window delta. */
149
+ function filterTaskDelta(paths: readonly string[]): string[] {
150
+ return paths.filter((entry) => {
151
+ const normalized = normalizePath(entry);
152
+ return !RUNTIME_ROOT_PREFIXES.some((prefix) =>
153
+ normalized.startsWith(prefix),
154
+ );
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Decide whether `update_goal_task status=complete` may proceed for a task.
160
+ *
161
+ * The checks run in this order, and each one is unconditional:
162
+ *
163
+ * 1. a worker dispatch must have been observed;
164
+ * 2. the dispatch must have been reviewed with `report_work`;
165
+ * 3. the recorded verification must have run and passed;
166
+ * 4. the report's files must reconcile with the window delta — a claimed file
167
+ * nobody changed is a false claim (hold), while a delta file the report
168
+ * omitted is only a warning, because the delta also carries tool-runtime
169
+ * noise no report can be expected to declare.
170
+ *
171
+ * Step 3 must not be reachable only through `decideTaskTick`: that function
172
+ * reports file-set mismatches before it looks at verification, so delegating
173
+ * the whole decision to it let a failed or missing verification through
174
+ * whenever the delta happened to contain an extra file.
175
+ */
176
+ export function evaluateTickGate(
177
+ tracker: DispatchReviewTracker,
178
+ input: { taskId: string; observedChangedFiles?: string[] },
179
+ ): TickGateDecision {
180
+ if (!tracker.dispatched(input.taskId)) {
181
+ return {
182
+ allow: false,
183
+ reason: `Task ${input.taskId} has no observed opsx-worker dispatch. Agent mode implements every task through a dispatch: dispatch opsx-worker for it, review the diff, then tick.`,
184
+ };
185
+ }
186
+
187
+ const report = tracker.reportFor(input.taskId);
188
+ if (!report) {
189
+ return {
190
+ allow: false,
191
+ reason: `The dispatch for task ${input.taskId} has not been reviewed. Call report_work with taskId "${input.taskId}", the changed files you verified, and the verification command and result, then tick.`,
192
+ };
193
+ }
194
+
195
+ const result = report.selfVerification.result;
196
+ // The structured verdict wins: when the report states it explicitly, the
197
+ // free-text scan is never consulted (spec: 门控判断 MUST 依据结构化裁决字段,
198
+ // MUST NOT 依赖解析子 agent 的自由文本). It remains the fallback for reports
199
+ // that carry only a result string.
200
+ const structured = report.verificationPassed;
201
+ const verification = {
202
+ ran:
203
+ structured !== undefined ||
204
+ report.selfVerification.command !== undefined ||
205
+ result !== undefined,
206
+ passed: structured !== undefined ? structured : !looksFailed(result),
207
+ ...(result !== undefined ? { evidence: result } : {}),
208
+ };
209
+ if (!verification.ran) {
210
+ return {
211
+ allow: false,
212
+ reason:
213
+ "The task's verification has not been run; tick only after it passes.",
214
+ };
215
+ }
216
+ if (!verification.passed) {
217
+ return {
218
+ allow: false,
219
+ reason: `The task's verification failed${result ? `: ${result}` : ""}. Fix and re-verify before ticking.`,
220
+ };
221
+ }
222
+
223
+ // Reconciliation runs on the noise-filtered delta, so a report is never
224
+ // accused of a phantom claim just because a probe log was filtered out, and
225
+ // a build-output task (dist/, coverage/) is never filtered out of its own
226
+ // evidence.
227
+ const observed = filterTaskDelta(
228
+ input.observedChangedFiles ?? report.changedFiles,
229
+ );
230
+ const decision = decideTaskTick(report, {
231
+ observedChangedFiles: observed,
232
+ verification,
233
+ });
234
+ if (decision.kind === "tick") {
235
+ return { allow: true, warnings: [] };
236
+ }
237
+
238
+ // After the verification pre-check the only possible hold is a file-set
239
+ // mismatch. Distinguish the two directions by set membership, never by the
240
+ // wording of the hold reason.
241
+ const observedSet = new Set(observed.map(normalizePath));
242
+ const claimedSet = new Set(report.changedFiles.map(normalizePath));
243
+ const phantom = [...claimedSet].filter((file) => !observedSet.has(file));
244
+ if (phantom.length > 0) {
245
+ return { allow: false, reason: decision.reason };
246
+ }
247
+ const unclaimed = [...observedSet].filter((file) => !claimedSet.has(file));
248
+ if (unclaimed.length > 0) {
249
+ return {
250
+ allow: true,
251
+ warnings: [
252
+ `The window delta contains file(s) the report did not declare: ${unclaimed.join(", ")}. Confirm none of them belongs to this task before ticking.`,
253
+ ],
254
+ };
255
+ }
256
+ return { allow: false, reason: decision.reason };
257
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Final review gate: point one opsx goal's completion audit at the custom
3
+ * opsx-reviewer, and turn the audit's structured verdict into the flow's next
4
+ * action (design D3/D10; openspec change add-pi-openspec-x, task 8.3).
5
+ *
6
+ * goal-x's completion transaction runs an independent audit BEFORE committing
7
+ * completion, so the final gate needs no plugin-side verdict channel:
8
+ *
9
+ * - S1 (per-goal auditor override, goal-x fork): `setGoalAuditorOverride`
10
+ * attaches the opsx-reviewer request/prompt settings to exactly one goal id.
11
+ * - S2 (cross-extension resolver, goal-x fork): `registerAuditorAgentResolver`
12
+ * supplies the opsx-reviewer runtime definition, which file-based preflight
13
+ * discovery cannot see.
14
+ *
15
+ * Both are keyed to the opsx goal, so a user's ordinary `/goal` keeps the
16
+ * default auditor and checklist. A disapproved audit leaves the goal active
17
+ * (goal-x semantics): the flow fixes and re-reviews until approved, then guides
18
+ * the user to archive — the plugin never archives for them.
19
+ */
20
+ import type { RuntimeAgentDefinition } from "@xzzpig/pi-subagents/agents";
21
+
22
+ import { OPSX_AGENT_DEFINITIONS } from "./agents.ts";
23
+
24
+ export const OPSX_REVIEWER_AGENT_NAME = "opsx-reviewer";
25
+
26
+ /** The four review dimensions injected into the auditor's checklist. */
27
+ export const OPSX_REVIEW_CHECKLIST = [
28
+ "Plan conformance: every task and requirement in tasks.md/design is verifiably satisfied or reported as a finding.",
29
+ "Code quality: the change is coherent, follows project conventions, and has no obvious defects.",
30
+ "Verification evidence: every claim is backed by real, reproducible command output.",
31
+ "Scope fidelity: every changed file belongs to the plan; unrequested changes are findings.",
32
+ ];
33
+
34
+ /** The extra instructions injected into the auditor's prompt. */
35
+ export const OPSX_REVIEW_INSTRUCTIONS = [
36
+ "This goal was created by pi-openspec-x for an OpenSpec change. Review the execution-window delta injected in your dispatch context, not the whole repository.",
37
+ "Your verdict drives a fix/re-review loop: disapprove with concrete, actionable findings so the next round can address them exactly.",
38
+ ].join("\n");
39
+
40
+ /** Per-goal auditor settings the S1 override carries. */
41
+ export interface OpsxAuditorOverride {
42
+ agent: string;
43
+ checklistExtra: string[];
44
+ instructions: string;
45
+ }
46
+
47
+ /** Build the per-goal auditor override for an opsx goal. */
48
+ export function buildOpsxAuditorOverride(): OpsxAuditorOverride {
49
+ return {
50
+ agent: OPSX_REVIEWER_AGENT_NAME,
51
+ checklistExtra: [...OPSX_REVIEW_CHECKLIST],
52
+ instructions: OPSX_REVIEW_INSTRUCTIONS,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * The S2 resolver: answer only for the opsx-reviewer name, with the runtime
58
+ * definition this plugin registered. Every other name (including the default
59
+ * `goal-auditor`) is a miss, so ordinary goals keep their original path.
60
+ *
61
+ * The resolver is a module-level singleton so repeated installs register the
62
+ * same function reference.
63
+ */
64
+ let opsxResolverSingleton:
65
+ | ((agentName: string) => RuntimeAgentDefinition | undefined)
66
+ | undefined;
67
+
68
+ export function createOpsxAuditorResolver(): (
69
+ agentName: string,
70
+ ) => RuntimeAgentDefinition | undefined {
71
+ opsxResolverSingleton ??= (agentName: string) =>
72
+ agentName === OPSX_REVIEWER_AGENT_NAME
73
+ ? OPSX_AGENT_DEFINITIONS[OPSX_REVIEWER_AGENT_NAME]
74
+ : undefined;
75
+ return opsxResolverSingleton;
76
+ }
77
+
78
+ /** The slice of goal-x the final gate needs (structurally the S1/S2 APIs). */
79
+ export interface OpsxAuditorInstaller {
80
+ setGoalAuditorOverride(
81
+ goalId: string,
82
+ override: Record<string, unknown>,
83
+ ): void;
84
+ registerAuditorAgentResolver(
85
+ resolver: (agentName: string) => RuntimeAgentDefinition | undefined,
86
+ ): () => void;
87
+ }
88
+
89
+ /**
90
+ * Register the S2 resolver once for the process. Idempotent: repeated calls
91
+ * hand goal-x the same function reference, which its registry dedupes.
92
+ */
93
+ export function registerOpsxAuditorResolver(goalX: {
94
+ registerAuditorAgentResolver(
95
+ resolver: (agentName: string) => RuntimeAgentDefinition | undefined,
96
+ ): () => void;
97
+ }): () => void {
98
+ return goalX.registerAuditorAgentResolver(createOpsxAuditorResolver());
99
+ }
100
+
101
+ /**
102
+ * Install the opsx final-audit configuration for one goal: the per-goal
103
+ * override plus the cross-extension resolver. Returns the resolver dispose
104
+ * function (the override lives for the session; goal-x clears it with the
105
+ * goal). Throws only if goal-x rejects the override (a plugin bug, not a user
106
+ * error).
107
+ */
108
+ export function installOpsxAuditorForGoal(
109
+ goalX: OpsxAuditorInstaller,
110
+ goalId: string,
111
+ ): { disposeResolver: () => void } {
112
+ goalX.setGoalAuditorOverride(goalId, { ...buildOpsxAuditorOverride() });
113
+ const disposeResolver = registerOpsxAuditorResolver(goalX);
114
+ return { disposeResolver };
115
+ }
116
+
117
+ /** The structured completion-audit result the gate consumes. */
118
+ export interface FinalAuditResult {
119
+ approved: boolean;
120
+ report: string;
121
+ findings: string[];
122
+ }
123
+
124
+ export type FinalGateDecision =
125
+ | { kind: "approved"; report: string }
126
+ | { kind: "fix"; findings: string[] };
127
+
128
+ /**
129
+ * Map the completion audit's structured verdict to the flow's next action.
130
+ * `approved` completes the goal (goal-x already committed it); otherwise the
131
+ * goal stays active and the findings drive the fix round.
132
+ */
133
+ export function decideFinalGate(result: FinalAuditResult): FinalGateDecision {
134
+ if (result.approved) {
135
+ return { kind: "approved", report: result.report };
136
+ }
137
+ return {
138
+ kind: "fix",
139
+ findings:
140
+ result.findings.length > 0
141
+ ? [...result.findings]
142
+ : [
143
+ "The completion audit disapproved without naming findings; re-run the audit with concrete findings.",
144
+ ],
145
+ };
146
+ }
147
+
148
+ /**
149
+ * The archive guidance shown after approval. The plugin never archives: the
150
+ * user runs the openspec archive flow themselves.
151
+ */
152
+ export function renderArchiveGuidance(changeId: string): string {
153
+ return [
154
+ `[OPSX FINAL AUDIT APPROVED change=${changeId}]`,
155
+ `The completion audit approved change "${changeId}". The goal is complete.`,
156
+ `Archive when you are ready: run the openspec archive flow (e.g. \`openspec archive "${changeId}"\`). pi-openspec-x never archives for you.`,
157
+ ].join("\n");
158
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Flow entries, the turn status snapshot, and the progress line renderers
3
+ * (design D8; openspec change add-pi-openspec-x, task 9.1).
4
+ *
5
+ * Flow state is recorded as custom session entries (mode in/out, phase change,
6
+ * verdict, task ticked) plus a `display: false`-style status snapshot appended
7
+ * each turn. Custom entries never enter the LLM context, so the snapshot keeps
8
+ * the interface recoverable after a compaction without perturbing the prompt
9
+ * cache. The renderers turn entries and messages into compact progress lines.
10
+ *
11
+ * The state model and text renderers are pure; the registration wiring only
12
+ * wraps them in minimal components.
13
+ */
14
+ import type {
15
+ ExtensionAPI,
16
+ ExtensionContext,
17
+ ExtensionHandler,
18
+ TurnEndEvent,
19
+ TurnEndEventResult,
20
+ } from "@earendil-works/pi-coding-agent";
21
+
22
+ export const OPSX_ENTRY_TYPES = {
23
+ modeEntered: "pi-openspec-x/mode-entered",
24
+ modeExited: "pi-openspec-x/mode-exited",
25
+ phaseChanged: "pi-openspec-x/phase-changed",
26
+ verdict: "pi-openspec-x/verdict",
27
+ approval: "pi-openspec-x/approval",
28
+ taskTicked: "pi-openspec-x/task-ticked",
29
+ rollback: "pi-openspec-x/rollback",
30
+ progress: "pi-openspec-x/progress",
31
+ statusSnapshot: "pi-openspec-x/status-snapshot",
32
+ } as const;
33
+
34
+ type OpsxEntryType = (typeof OPSX_ENTRY_TYPES)[keyof typeof OPSX_ENTRY_TYPES];
35
+
36
+ type OpsxFlowMode = "plan" | "agent" | "direct";
37
+
38
+ export interface OpsxFlowState {
39
+ mode?: OpsxFlowMode;
40
+ changeId?: string;
41
+ phase?: string;
42
+ round?: number;
43
+ tasksTotal?: number;
44
+ tasksDone?: number;
45
+ lastVerdict?: string;
46
+ updatedAt?: string;
47
+ /** Set on the snapshot written when the flow ends, so a restored snapshot
48
+ * from a previous process is not shown as if the flow were still running. */
49
+ ended?: boolean;
50
+ }
51
+
52
+ /** Append a flow entry. Best-effort: a failed append never breaks the flow. */
53
+ export function appendFlowEntry(
54
+ pi: Pick<ExtensionAPI, "appendEntry">,
55
+ type: OpsxEntryType,
56
+ data: Record<string, unknown>,
57
+ ): void {
58
+ try {
59
+ pi.appendEntry(type, data);
60
+ } catch {
61
+ // Observability is best-effort.
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Append the per-turn status snapshot (a custom entry, so it stays out of the
67
+ * LLM context and survives compaction for interface recovery).
68
+ */
69
+ export function appendStatusSnapshot(
70
+ pi: Pick<ExtensionAPI, "appendEntry">,
71
+ state: OpsxFlowState,
72
+ ): OpsxFlowState {
73
+ const snapshot: OpsxFlowState = {
74
+ ...state,
75
+ updatedAt: new Date().toISOString(),
76
+ };
77
+ appendFlowEntry(pi, OPSX_ENTRY_TYPES.statusSnapshot, {
78
+ ...snapshot,
79
+ });
80
+ return snapshot;
81
+ }
82
+
83
+ /** Minimal session-entry view (pi's CustomEntry shape). */
84
+ export interface SessionEntryLike {
85
+ type?: string;
86
+ customType?: string;
87
+ data?: unknown;
88
+ }
89
+
90
+ /**
91
+ * Recover the latest flow state from session entries. Used after a compaction
92
+ * (or a reload) to restore the status line. Returns undefined when no snapshot
93
+ * was ever written.
94
+ */
95
+ export function restoreFlowState(
96
+ entries: readonly SessionEntryLike[],
97
+ ): OpsxFlowState | undefined {
98
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
99
+ const entry = entries[index];
100
+ if (
101
+ entry?.type === "custom" &&
102
+ entry.customType === OPSX_ENTRY_TYPES.statusSnapshot &&
103
+ entry.data &&
104
+ typeof entry.data === "object"
105
+ ) {
106
+ return { ...(entry.data as OpsxFlowState) };
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ function asText(value: unknown): string | undefined {
113
+ return typeof value === "string" && value.trim() ? value : undefined;
114
+ }
115
+
116
+ function asNumber(value: unknown): number | undefined {
117
+ return typeof value === "number" && Number.isFinite(value)
118
+ ? value
119
+ : undefined;
120
+ }
121
+
122
+ /** Render one flow entry as a compact progress line. */
123
+ export function renderFlowEntryText(
124
+ customType: string,
125
+ data: Record<string, unknown> | undefined,
126
+ ): string | undefined {
127
+ const change = asText(data?.changeId) ?? "?";
128
+ switch (customType) {
129
+ case OPSX_ENTRY_TYPES.modeEntered:
130
+ return `◆ opsx ${asText(data?.mode) ?? "?"} mode entered (${change})`;
131
+ case OPSX_ENTRY_TYPES.modeExited:
132
+ return `◇ opsx ${asText(data?.mode) ?? "?"} mode exited (${change})`;
133
+ case OPSX_ENTRY_TYPES.phaseChanged:
134
+ return `→ phase ${asText(data?.phase) ?? "?"} (${change})`;
135
+ case OPSX_ENTRY_TYPES.verdict: {
136
+ const round = asNumber(data?.round);
137
+ return `⚖ ${asText(data?.verdict) ?? "?"}${round ? ` round ${round}` : ""} (${change})`;
138
+ }
139
+ case OPSX_ENTRY_TYPES.approval:
140
+ return `✍ plan ${asText(data?.decision) ?? "?"} (${change})`;
141
+ case OPSX_ENTRY_TYPES.taskTicked:
142
+ return `☑ ${asText(data?.taskId) ?? "?"} (${change})`;
143
+ case OPSX_ENTRY_TYPES.rollback: {
144
+ const restored = asNumber(data?.restored) ?? 0;
145
+ const deleted = asNumber(data?.deleted) ?? 0;
146
+ return `↩ rollback: ${restored} restored, ${deleted} removed (${change})`;
147
+ }
148
+ default:
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ /** Render the session status line from the current flow state. */
154
+ export function renderStatusLine(state: OpsxFlowState): string {
155
+ const parts: string[] = ["opsx"];
156
+ if (state.mode) parts.push(state.mode);
157
+ if (state.changeId) parts.push(state.changeId);
158
+ if (state.phase) parts.push(state.phase);
159
+ if (typeof state.round === "number") parts.push(`round ${state.round}`);
160
+ if (
161
+ typeof state.tasksTotal === "number" &&
162
+ typeof state.tasksDone === "number"
163
+ ) {
164
+ parts.push(`tasks ${state.tasksDone}/${state.tasksTotal}`);
165
+ }
166
+ if (state.lastVerdict) parts.push(`last ${state.lastVerdict}`);
167
+ return parts.join(" · ");
168
+ }
169
+
170
+ /** A minimal TUI component (render + invalidate are required by pi's Component). */
171
+ interface MinimalComponent {
172
+ render(width: number): string[];
173
+ invalidate(): void;
174
+ }
175
+
176
+ function lineComponent(text: string): MinimalComponent {
177
+ return { render: () => [text], invalidate: () => {} };
178
+ }
179
+
180
+ /**
181
+ * Register the entry renderers for the flow entries. Each renders a single
182
+ * compact line; unknown shapes fall back to the default renderer.
183
+ */
184
+ export function registerFlowRenderers(pi: ExtensionAPI): void {
185
+ const entryTypes: OpsxEntryType[] = [
186
+ OPSX_ENTRY_TYPES.modeEntered,
187
+ OPSX_ENTRY_TYPES.modeExited,
188
+ OPSX_ENTRY_TYPES.phaseChanged,
189
+ OPSX_ENTRY_TYPES.verdict,
190
+ OPSX_ENTRY_TYPES.approval,
191
+ OPSX_ENTRY_TYPES.taskTicked,
192
+ OPSX_ENTRY_TYPES.rollback,
193
+ ];
194
+ for (const entryType of entryTypes) {
195
+ pi.registerEntryRenderer(entryType, (entry) => {
196
+ const data =
197
+ entry.data && typeof entry.data === "object"
198
+ ? (entry.data as Record<string, unknown>)
199
+ : undefined;
200
+ const text = renderFlowEntryText(entryType, data);
201
+ return text ? lineComponent(text) : undefined;
202
+ });
203
+ }
204
+ }
205
+
206
+ /** The current flow state, recovered from the session snapshot. */
207
+ function currentFlowState(ctx: ExtensionContext): OpsxFlowState | undefined {
208
+ try {
209
+ return restoreFlowState(ctx.sessionManager.getEntries());
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Build the turn_end handler that appends the per-turn status snapshot. The
217
+ * snapshot is a custom entry (never sent to the LLM), so it keeps the
218
+ * interface recoverable after a compaction without touching the prompt cache.
219
+ */
220
+ export function createStatusSnapshotTurnEndHandler(
221
+ pi: Pick<ExtensionAPI, "appendEntry">,
222
+ getState: (sessionId: string) => OpsxFlowState | undefined,
223
+ ): ExtensionHandler<TurnEndEvent, TurnEndEventResult> {
224
+ return (_event: TurnEndEvent, ctx: ExtensionContext): void => {
225
+ let sessionId: string;
226
+ try {
227
+ sessionId = ctx.sessionManager?.getSessionId() ?? "unknown";
228
+ } catch {
229
+ return;
230
+ }
231
+ const live = getState(sessionId);
232
+ if (live) {
233
+ appendStatusSnapshot(pi, live);
234
+ }
235
+ // A process restart leaves the in-memory flow registry empty; the last
236
+ // persisted snapshot is then the only record of where the flow stood, so
237
+ // restore it instead of showing nothing (spec: 流程快照 SHALL 随会话持久化,
238
+ // 并在会话压缩后仍能恢复显示当前状态).
239
+ const restored = live ? undefined : currentFlowState(ctx);
240
+ const state = live ?? (restored?.ended ? undefined : restored);
241
+ if (!state) {
242
+ // A finished (or never-started) flow must not leave a stale mode/phase
243
+ // on the footer; clearing is idempotent when nothing was set.
244
+ try {
245
+ if (ctx.hasUI) ctx.ui.setStatus("opsx", undefined);
246
+ } catch {
247
+ // Status is best-effort.
248
+ }
249
+ return;
250
+ }
251
+ // The session status line keeps the current mode/phase visible (task 9.2).
252
+ try {
253
+ if (ctx.hasUI) ctx.ui.setStatus("opsx", renderStatusLine(state));
254
+ } catch {
255
+ // Status is best-effort.
256
+ }
257
+ };
258
+ }