pi-pier 0.3.0 → 0.4.1

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.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -1,15 +1,11 @@
1
1
  /**
2
- * D102 Evidence-Preserving Reducer Invoker & Tool Result Handler.
2
+ * D102 Evidence-Preserving Reducer invoker: intercepts `bash` diagnostic tool results and replaces
3
+ * the log block with a verified receipt.
3
4
  *
4
- * Implements:
5
- * - Interception of diagnostic command tool results from `bash`.
6
- * - Full untruncated output retrieval from Pi temporary files.
7
- * - P0 content-addressed archival of original source log before replacement.
8
- * - Secret pattern detection and project trust verification (security boundaries).
9
- * - Fast in-process model invocation with timeout protection (default 5s).
10
- * - Zero-tolerance byte-for-byte quotation validation against original source.
11
- * - Block-level content replacement (preserves write-lock warnings).
12
- * - Fail-open resilience: errors or validation failures transparently fallback to full text.
5
+ * Fail-open throughout — every stage that cannot be satisfied (untrusted project, size, credential
6
+ * shape, failed archive, model error, unverifiable quote, no size win) returns undefined and the
7
+ * original text reaches the model unchanged. The swap happens only after a receipt validates
8
+ * byte-for-byte against a source that was archived first.
13
9
  */
14
10
 
15
11
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
@@ -41,7 +37,7 @@ import {
41
37
  storeContentAddressedObject,
42
38
  } from './efficiency-store.ts';
43
39
  import type { EvidencePreservingReducerConfig } from './efficiency-config-core.ts';
44
- import { diagnosticGateRequest, evaluateDiagnosticGate } from './jev-core.ts';
40
+ import { diagnosticGateRequest, evaluateDiagnosticGate, isDiagnosticGateUnanswered } from './jev-core.ts';
45
41
  import type { JevRuntime } from './jev-client.ts';
46
42
 
47
43
  /** P0-1 seam: the runtime plus a live confidence gate (config can reload mid-session). */
@@ -67,43 +63,39 @@ export interface ReducerInvocationResult {
67
63
  usage?: UsageTotals;
68
64
  }
69
65
 
66
+ /** The subset of pi's AssistantMessage the reducer reads. */
67
+ type ReducerModelOutput = {
68
+ content?: readonly unknown[];
69
+ text?: unknown;
70
+ usage?: UsageLike;
71
+ };
72
+
73
+ type ReducerModel = NonNullable<ExtensionContext['model']>;
74
+
75
+ interface ReducerCandidate {
76
+ command: string;
77
+ block: { type: string; text?: string; [k: string]: unknown };
78
+ text: string;
79
+ fullOutputPath?: string;
80
+ fullOutputSource: 'details' | 'notice' | 'none';
81
+ isTruncated: boolean;
82
+ }
83
+
84
+ type ReducerLog = (record: Record<string, unknown>) => Promise<void>;
85
+
70
86
  let untrustedWarningEmitted = false;
71
87
 
72
- export async function handleReducerToolResult(
88
+ /** Steps 1–5: the trust boundary sits BEFORE the jev gate — an untrusted project must not send
89
+ * commands to a third-party API even with user-level EPR on, and a refusing host must not be second-guessed. */
90
+ function resolveCandidate(
73
91
  event: ToolResultEventLike,
74
92
  ctx: ExtensionContext,
75
93
  config: EvidencePreservingReducerConfig,
76
- opts?: { epoch?: number; jev?: JevEprGateDependency },
77
- ): Promise<ReducerInvocationResult | undefined> {
78
- if (!config.enabled) return undefined;
79
- const epoch = opts?.epoch ?? 0;
80
-
81
- // 1. Candidate command & tool gate: strictly bash only
94
+ ): ReducerCandidate | undefined {
82
95
  if (event.toolName !== 'bash') return undefined;
83
96
  const command = typeof event.input?.command === 'string' ? event.input.command.trim() : '';
84
97
  if (!command) return undefined;
85
- if (!isDiagnosticCommand(command)) {
86
- // P0-1 (RFC docs/rfc-jev-integration.md §3): the regex missed — widen the
87
- // funnel through jev before giving up. The regex fast path stays
88
- // authoritative; any jev failure keeps the "not diagnostic" verdict.
89
- const gate = opts?.jev;
90
- if (!gate) return undefined;
91
- const result = await gate.ask(diagnosticGateRequest(command), {
92
- questionId: 'epr-diagnostic-gate',
93
- timeoutMs: 1000, // tool-result path; only regex-missed commands reach here
94
- sessionId: ctx.sessionManager?.getSessionId?.(),
95
- extra: { site: 'epr-gate', commandSha256: sha256Hex(command) },
96
- enrich: ({ answers }) => {
97
- if (!answers) return {};
98
- const verdict = evaluateDiagnosticGate(answers, gate.getMinConfidence());
99
- return { verdict: verdict.hit ? 'hit' : verdict.reason, choice: verdict.choice, noul: verdict.noul, confidence: verdict.confidence };
100
- },
101
- });
102
- if (!result.ok) return undefined;
103
- if (!evaluateDiagnosticGate(result.answers, gate.getMinConfidence()).hit) return undefined;
104
- }
105
98
 
106
- // 2. Project trust boundary check
107
99
  const isTrusted =
108
100
  typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
109
101
  ? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
@@ -116,209 +108,263 @@ export async function handleReducerToolResult(
116
108
  return undefined;
117
109
  }
118
110
 
119
- // 3. Find primary log text block
120
- const logBlock = event.content.find((b) => b && b.type === 'text' && typeof b.text === 'string');
121
- if (!logBlock || typeof logBlock.text !== 'string') return undefined;
111
+ const block = event.content.find((b) => b && b.type === 'text' && typeof b.text === 'string');
112
+ if (!block || typeof block.text !== 'string') return undefined;
113
+ const text = block.text;
122
114
 
123
- // 4. Retrieve complete untruncated source if available
124
115
  const maxChars = config.maxChars ?? DEFAULT_MAX_CHARS;
125
116
  const detailPath = typeof event.details?.fullOutputPath === 'string' ? event.details.fullOutputPath : undefined;
126
- // Pi repeats the path inside the truncation notice that ships with the result text; a replayed
127
- // or re-shaped event can keep only that text, and reducing the preview would lose the evidence.
128
- const noticePath = fullOutputPathFromNotice(logBlock.text);
129
- const fullOutputPath = detailPath ?? noticePath;
130
- const fullOutputSource = detailPath ? 'details' : noticePath ? 'notice' : 'none';
117
+ // Pi repeats the path inside the truncation notice that ships with the result text; a replayed or
118
+ // re-shaped event can keep only that text, and reducing the preview would lose the evidence.
119
+ const noticePath = fullOutputPathFromNotice(text);
131
120
  const isTruncated =
132
121
  (event.details?.truncation as { truncated?: boolean } | undefined)?.truncated === true;
133
122
 
134
- const sessionDir = ctx.sessionManager?.getSessionDir?.();
135
- const sessionId = ctx.sessionManager?.getSessionId?.();
136
- const sessionRoot = resolveSessionRoot(sessionDir, sessionId);
137
-
138
- // If truncated and no fullOutputPath or fullOutputPath cannot be read safely, fail-open!
139
- if (isTruncated && !fullOutputPath) {
140
- if (config.logEnabled && sessionRoot) {
141
- await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
142
- commandSha256: sha256Hex(command),
143
- model: config.model ?? 'default',
144
- verificationOk: false,
145
- reason: 'truncated-source',
146
- action: 'fallback_full_text',
147
- fullOutputSource,
148
- });
149
- }
123
+ // Cheap reducibility precondition: a truncated output is presumed large (that is why pi truncated
124
+ // it); anything else must clear minBytes/maxChars on the preview, which for non-truncated results
125
+ // IS the full body. Commands below this line never pay for the gate call or the full read.
126
+ if (!isTruncated && (Buffer.byteLength(text, 'utf8') < (config.minBytes ?? DEFAULT_MIN_BYTES) || text.length > maxChars)) {
150
127
  return undefined;
151
128
  }
152
129
 
153
- let body = logBlock.text;
154
- if (fullOutputPath) {
155
- const full = await readBashFullOutput(fullOutputPath, maxChars);
156
- if (!full) {
157
- if (config.logEnabled && sessionRoot) {
158
- await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
159
- commandSha256: sha256Hex(command),
160
- model: config.model ?? 'default',
161
- verificationOk: false,
162
- reason: 'truncated-source',
163
- action: 'fallback_full_text',
164
- fullOutputSource,
165
- });
166
- }
167
- // Log was truncated but full file could not be read safely -> fallback to avoid hallucinated receipts
168
- return undefined;
169
- }
170
- body = full.content;
171
- }
172
-
173
- const sourceBytes = Buffer.byteLength(body, 'utf8');
174
- const minBytes = config.minBytes ?? DEFAULT_MIN_BYTES;
175
- if (sourceBytes < minBytes || body.length > maxChars) return undefined;
130
+ const fullOutputPath = detailPath ?? noticePath;
131
+ return {
132
+ command,
133
+ block,
134
+ text,
135
+ ...(fullOutputPath ? { fullOutputPath } : {}),
136
+ fullOutputSource: detailPath ? 'details' : noticePath ? 'notice' : 'none',
137
+ isTruncated,
138
+ };
139
+ }
176
140
 
177
- // 5. Secret detection check
178
- if (containsLikelySecret(body)) {
179
- if (config.logEnabled && sessionRoot) {
180
- await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
181
- commandSha256: sha256Hex(command),
182
- sourceBytes,
183
- model: config.model ?? 'default',
184
- verificationOk: false,
185
- reason: 'likely-secret',
186
- action: 'fallback_full_text',
187
- // Which shape tripped the gate — the 2026-09-17 trial had 10 fallbacks
188
- // with zero evidence of what matched (test names vs real credentials).
189
- secretSnippet: extractLikelySecretMatch(body),
190
- });
191
- }
192
- return undefined; // Fail-open on sensitive data
193
- }
141
+ /**
142
+ * Step 6, jev first (RFC §3 P0-1): a confident jev rejection is authoritative even for regex-listed
143
+ * commands; the regex list decides only when jev is absent, fails or answers below the confidence
144
+ * gate (credential-shaped command lines stay local).
145
+ */
146
+ async function isDiagnosticCandidate(
147
+ command: string,
148
+ gate: JevEprGateDependency | undefined,
149
+ sessionId: string | undefined,
150
+ ): Promise<boolean> {
151
+ const regexHit = isDiagnosticCommand(command);
152
+ if (!gate || containsLikelySecret(command)) return regexHit;
153
+ const result = await gate.ask(diagnosticGateRequest(command), {
154
+ questionId: 'epr-diagnostic-gate',
155
+ timeoutMs: 2500, // cold TLS hit 1002ms at 1s; at 1500ms a real session still saw 14/73 calls (19%) time out — 2.5s keeps the gate decisive without stalling reducers
156
+ sessionId,
157
+ // regex_hit makes the flip observable: how often and in which direction jev overrides the regex list (jev.jsonl).
158
+ extra: { site: 'epr-gate', commandSha256: sha256Hex(command), regexHit },
159
+ enrich: ({ answers }) => {
160
+ if (!answers) return {};
161
+ const verdict = evaluateDiagnosticGate(answers, gate.getMinConfidence());
162
+ return {
163
+ verdict: verdict.hit ? 'hit' : verdict.reason,
164
+ choice: verdict.choice,
165
+ noul: verdict.noul,
166
+ confidence: verdict.confidence,
167
+ };
168
+ },
169
+ });
170
+ if (!result.ok) return regexHit;
171
+ const verdict = evaluateDiagnosticGate(result.answers, gate.getMinConfidence());
172
+ if (verdict.hit) return true;
173
+ return isDiagnosticGateUnanswered(verdict) ? regexHit : false;
174
+ }
194
175
 
195
- if (!sessionRoot) return undefined;
176
+ /** Step 7: the untruncated source; both failure modes write the same `truncated-source` evidence
177
+ * row, which lands only for candidates that got here — gate-rejected commands never read a file. */
178
+ async function readSource(
179
+ candidate: ReducerCandidate,
180
+ config: EvidencePreservingReducerConfig,
181
+ log: ReducerLog,
182
+ ): Promise<string | null> {
183
+ const truncatedRow = (): Promise<void> =>
184
+ log({
185
+ model: config.model ?? 'default',
186
+ verificationOk: false,
187
+ reason: 'truncated-source',
188
+ action: 'fallback_full_text',
189
+ fullOutputSource: candidate.fullOutputSource,
190
+ });
196
191
 
197
- const sourceHash = sha256Hex(body);
198
- const sourceLines = countLines(body);
199
- const archivePath = reducerObjectPath(sessionRoot, sourceHash);
192
+ if (candidate.isTruncated && !candidate.fullOutputPath) {
193
+ await truncatedRow();
194
+ return null; // Truncated with no recoverable full log: fail open rather than reduce the preview.
195
+ }
196
+ if (!candidate.fullOutputPath) return candidate.text;
197
+ const full = await readBashFullOutput(candidate.fullOutputPath, config.maxChars ?? DEFAULT_MAX_CHARS);
198
+ if (!full) {
199
+ await truncatedRow();
200
+ return null;
201
+ }
202
+ return full.content;
203
+ }
200
204
 
201
- // 6. P0: Mandatory source archive before any receipt modification
205
+ /** Steps 7/P0: the source archive is mandatory before any modification; a failed write fails open. */
206
+ async function archiveSource(sessionRoot: string, body: string): Promise<string | null> {
207
+ const path = reducerObjectPath(sessionRoot, sha256Hex(body));
202
208
  try {
203
- await storeContentAddressedObject(archivePath, body);
209
+ await storeContentAddressedObject(path, body);
204
210
  } catch {
205
- return undefined; // Fail-open if storage fails
211
+ return null;
206
212
  }
213
+ return path;
214
+ }
207
215
 
208
- // If localOnly mode, archival is complete; leave content unmodified
209
- if (config.localOnly) return undefined;
210
- // 7. Resolve model: configured cheap reducer first, session model as fallback
211
- // (2026-09-17: user directive — gemini-3.8-flash-high stays the reducer;
212
- // unavailability must degrade to the session model, not skip reduction).
213
- let targetModel: any = undefined;
214
- let reducerModelSource: 'configured' | 'session-fallback' = 'configured';
216
+ /** Step 8: the configured cheap reducer first, the session model as fallback. */
217
+ function resolveReducerModel(
218
+ ctx: ExtensionContext,
219
+ config: EvidencePreservingReducerConfig,
220
+ ): { model: ReducerModel; label: string; source: 'configured' | 'session-fallback' } | undefined {
221
+ let model: ReducerModel | undefined;
215
222
  if (config.model && typeof config.model === 'string' && config.model.includes('/')) {
216
223
  const [provider, ...rest] = config.model.split('/');
217
- const modelId = rest.join('/');
218
224
  try {
219
- targetModel = ctx.modelRegistry?.find?.(provider, modelId);
225
+ model = ctx.modelRegistry?.find?.(provider, rest.join('/'));
220
226
  } catch {
221
- targetModel = undefined;
227
+ model = undefined;
222
228
  }
223
229
  }
224
- if (!targetModel) {
225
- targetModel = ctx.model;
226
- reducerModelSource = 'session-fallback';
227
- }
228
- if (!targetModel || typeof ctx.modelRegistry?.complete !== 'function') {
229
- return undefined;
230
- }
231
- // Unified provider/id label: the 2026-09-17 logs split one session into
232
- // "cliproxy/gemini-3.8-flash-high" (fallback rows) and "gemini-3.8-flash-high"
233
- // (applied rows), fragmenting per-model grouping.
234
- const reducerModelLabel =
235
- typeof targetModel.provider === 'string' && typeof targetModel.id === 'string'
236
- ? `${targetModel.provider}/${targetModel.id}`
237
- : (config.model ?? 'unknown');
238
-
239
- // 8. Invoke model in-process with timeout
240
- const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
241
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
242
- const signal =
243
- ctx.signal && typeof (AbortSignal as any).any === 'function'
244
- ? (AbortSignal as any).any([ctx.signal, timeoutSignal])
245
- : timeoutSignal;
246
-
247
- const promptInput = reducerInputPrompt({
248
- command,
249
- isError: event.isError,
250
- sourceHash,
251
- sourceBytes,
252
- sourceLines,
253
- body,
254
- });
230
+ const source = model ? 'configured' : 'session-fallback';
231
+ if (!model) model = ctx.model;
232
+ if (!model || typeof ctx.modelRegistry?.complete !== 'function') return undefined;
233
+ const label =
234
+ model.provider && model.id ? `${model.provider}/${model.id}` : (config.model ?? 'unknown');
235
+ return { model, label, source };
236
+ }
255
237
 
238
+ /** Step 9: in-process model call under a total-latency budget through pi's own completion, so the
239
+ * reducer runs on the session's provider auth; failure text is returned for telemetry. */
240
+ async function invokeReducer(
241
+ ctx: ExtensionContext,
242
+ config: EvidencePreservingReducerConfig,
243
+ model: ReducerModel,
244
+ promptInput: string,
245
+ ): Promise<{ output: ReducerModelOutput; durationMs: number } | { error: string; durationMs: number }> {
246
+ const timeoutSignal = AbortSignal.timeout(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
247
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
256
248
  const startMs = Date.now();
257
- let modelResult: any;
258
249
  try {
259
- modelResult = await ctx.modelRegistry.complete(
260
- targetModel,
250
+ const output: ReducerModelOutput = await ctx.modelRegistry.complete(
251
+ model,
261
252
  {
262
253
  systemPrompt: reducerInstructions(),
263
254
  messages: [{ role: 'user', content: [{ type: 'text', text: promptInput }], timestamp: Date.now() }],
264
255
  },
265
- {
266
- maxTokens: config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
267
- cacheRetention: 'none',
268
- signal,
269
- },
256
+ { maxTokens: config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, cacheRetention: 'none', signal },
270
257
  );
258
+ return { output, durationMs: Date.now() - startMs };
271
259
  } catch (err) {
272
- // Fail-open, but no longer silent: the 2026-09-17 trial lost 3+ attempts
273
- // (timeout / provider error) with no jsonl trace at all.
274
- if (config.logEnabled) {
275
- await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
276
- commandSha256: sha256Hex(command),
277
- sourceBytes,
278
- model: reducerModelLabel,
279
- reducerModelSource,
280
- verificationOk: false,
281
- reason: 'invoke-failed',
282
- action: 'fallback_full_text',
283
- error: err instanceof Error ? err.message : String(err),
284
- durationMs: Date.now() - startMs,
285
- });
286
- }
287
- return undefined; // Fail-open on timeout or provider error
260
+ return { error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - startMs };
288
261
  }
262
+ }
289
263
 
290
- const modelOutput = Array.isArray(modelResult?.content)
291
- ? modelResult.content.map((b: any) => (typeof b.text === 'string' ? b.text : '')).join('')
292
- : typeof modelResult?.text === 'string'
293
- ? modelResult.text
294
- : '';
264
+ export async function handleReducerToolResult(
265
+ event: ToolResultEventLike,
266
+ ctx: ExtensionContext,
267
+ config: EvidencePreservingReducerConfig,
268
+ opts?: { epoch?: number; jev?: JevEprGateDependency },
269
+ ): Promise<ReducerInvocationResult | undefined> {
270
+ if (!config.enabled) return undefined;
271
+ const epoch = opts?.epoch ?? 0;
295
272
 
273
+ const candidate = resolveCandidate(event, ctx, config);
274
+ if (!candidate) return undefined;
275
+ const { command, block } = candidate;
276
+ const minBytes = config.minBytes ?? DEFAULT_MIN_BYTES;
277
+ const maxChars = config.maxChars ?? DEFAULT_MAX_CHARS;
278
+
279
+ const sessionId = ctx.sessionManager?.getSessionId?.();
280
+ const sessionRoot = resolveSessionRoot(ctx.sessionManager?.getSessionDir?.(), sessionId);
281
+ if (!sessionRoot) return undefined;
282
+
283
+ if (!(await isDiagnosticCandidate(command, opts?.jev, sessionId))) return undefined;
284
+
285
+ const log: ReducerLog = async (record) => {
286
+ if (!config.logEnabled) return;
287
+ await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
288
+ commandSha256: sha256Hex(command),
289
+ ...record,
290
+ });
291
+ };
292
+
293
+ const body = await readSource(candidate, config, log);
294
+ if (body === null) return undefined;
295
+
296
+ // Step 8: size gates on the resolved body (a truncated output's true size is only known here).
297
+ const sourceBytes = Buffer.byteLength(body, 'utf8');
298
+ if (sourceBytes < minBytes || body.length > maxChars) return undefined;
299
+
300
+ // Step 9: credential heuristic; `secretSnippet` names the shape that tripped the gate.
301
+ if (containsLikelySecret(body)) {
302
+ await log({
303
+ sourceBytes,
304
+ model: config.model ?? 'default',
305
+ verificationOk: false,
306
+ reason: 'likely-secret',
307
+ action: 'fallback_full_text',
308
+ secretSnippet: extractLikelySecretMatch(body),
309
+ });
310
+ return undefined;
311
+ }
312
+
313
+ const sourceHash = sha256Hex(body);
314
+ const sourceLines = countLines(body);
315
+ const archivePath = await archiveSource(sessionRoot, body);
316
+ if (archivePath === null) return undefined;
317
+ if (config.localOnly) return undefined; // Archival is the whole job in local-only mode.
318
+
319
+ const reducer = resolveReducerModel(ctx, config);
320
+ if (!reducer) return undefined;
321
+ /** Evidence row for a fail-open exit taken after the reducer model was resolved. */
322
+ const fallbackRow = (reason: string, durationMs: number, extra: Record<string, unknown> = {}): Promise<void> =>
323
+ log({
324
+ sourceBytes,
325
+ model: reducer.label,
326
+ reducerModelSource: reducer.source,
327
+ verificationOk: false,
328
+ reason,
329
+ action: 'fallback_full_text',
330
+ durationMs,
331
+ ...extra,
332
+ });
333
+
334
+ const invocation = await invokeReducer(
335
+ ctx,
336
+ config,
337
+ reducer.model,
338
+ reducerInputPrompt({ command, isError: event.isError, sourceHash, sourceBytes, sourceLines, body }),
339
+ );
340
+ if ('error' in invocation) {
341
+ await fallbackRow('invoke-failed', invocation.durationMs, { error: invocation.error });
342
+ return undefined;
343
+ }
344
+ const { output, durationMs } = invocation;
345
+
346
+ const blocks: readonly unknown[] | undefined = Array.isArray(output.content) ? output.content : undefined;
347
+ const modelOutput = blocks
348
+ ? blocks
349
+ .map((block) => {
350
+ const text = (block as { text?: unknown }).text;
351
+ return typeof text === 'string' ? text : '';
352
+ })
353
+ .join('')
354
+ : typeof output.text === 'string'
355
+ ? output.text
356
+ : '';
296
357
  if (!modelOutput.trim()) return undefined;
297
358
 
298
- // 9. Strict byte-for-byte quotation validation
299
359
  const validated = validateReceipt(modelOutput, sourceHash, body, event.isError);
300
360
  if (!validated.ok) {
301
- if (config.logEnabled) {
302
- const failReason = validated.reason;
303
- await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
304
- commandSha256: sha256Hex(command),
305
- sourceBytes,
306
- model: reducerModelLabel,
307
- reducerModelSource,
308
- verificationOk: false,
309
- reason: failReason,
310
- action: 'fallback_full_text',
311
- // invalid-json rows carried no evidence of what the model actually
312
- // returned (fences? truncation? prose?) — keep a sanitized head.
313
- ...(failReason === 'invalid-json' ? { rawOutputHead: JSON.stringify(modelOutput.slice(0, 200)) } : {}),
314
- durationMs: Date.now() - startMs,
315
- });
316
- }
361
+ // Keep a sanitized head: invalid-json rows must show what the model actually returned.
362
+ const head = validated.reason === 'invalid-json' ? { rawOutputHead: JSON.stringify(modelOutput.slice(0, 200)) } : {};
363
+ await fallbackRow(validated.reason, durationMs, head);
317
364
  return undefined;
318
365
  }
319
366
 
320
- const modelName = targetModel.id ?? config.model ?? 'default';
321
- const providerName = typeof targetModel.provider === 'string' ? targetModel.provider : undefined;
367
+ // Step 11: block-level replacement — only the log block, so write-lock warnings survive.
322
368
  const receipt = formatReceiptText({
323
369
  command,
324
370
  sourceHash,
@@ -326,47 +372,30 @@ export async function handleReducerToolResult(
326
372
  sourceLines,
327
373
  sourceArtifactPath: archivePath,
328
374
  validated: validated.value,
329
- model: modelName,
330
- provider: providerName,
331
- totalTokens: modelResult?.usage?.totalTokens,
375
+ model: reducer.model.id ?? config.model ?? 'default',
376
+ provider: typeof reducer.model.provider === 'string' ? reducer.model.provider : undefined,
377
+ totalTokens: output.usage?.totalTokens,
332
378
  });
333
-
334
379
  const receiptBytes = Buffer.byteLength(receipt, 'utf8');
335
- if (receiptBytes >= sourceBytes) {
336
- return undefined; // Must be smaller
337
- }
338
-
339
- // 10. Block-level replacement: only replace the log text block, preserving warnings
340
- const nextContent = event.content.map((b): { type: 'text'; text: string } =>
341
- (b === logBlock ? { ...b, text: receipt } : b) as { type: 'text'; text: string });
342
-
343
- // Backfill usage tokens: the nested reducer model call plus whatever the tool result already carried.
344
- // MUST stay a COMPLETE pi `Usage`: pi persists this onto the tool-result message and its footer
345
- // renders it through `addUsageToTotals`, which reads `usage.cost.total` unguarded. Emitting only
346
- // { input, output, totalTokens } killed the whole pi process with
347
- // "TypeError: Cannot read properties of undefined (reading 'total')" (observed 2026-09-13 in a
348
- // subagent pane, stack: FooterComponent.render -> addUsageToTotals). See docs/session-format.md.
349
- const nextUsage = mergeUsage(event.usage, modelResult?.usage);
380
+ if (receiptBytes >= sourceBytes) return undefined;
350
381
 
351
- if (config.logEnabled) {
352
- await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
353
- commandSha256: sha256Hex(command),
354
- sourceBytes,
355
- receiptBytes,
356
- grossSavedBytes: sourceBytes - receiptBytes,
357
- compressionRatio: Number((receiptBytes / sourceBytes).toFixed(3)),
358
- model: reducerModelLabel,
359
- reducerModelSource,
360
- verificationOk: true,
361
- action: 'applied',
362
- fullOutputSource,
363
- durationMs: Date.now() - startMs,
364
- });
365
- }
382
+ await log({
383
+ sourceBytes,
384
+ receiptBytes,
385
+ grossSavedBytes: sourceBytes - receiptBytes,
386
+ compressionRatio: Number((receiptBytes / sourceBytes).toFixed(3)),
387
+ model: reducer.label,
388
+ reducerModelSource: reducer.source,
389
+ verificationOk: true,
390
+ action: 'applied',
391
+ fullOutputSource: candidate.fullOutputSource,
392
+ durationMs,
393
+ });
366
394
 
367
395
  return {
368
- content: nextContent,
369
- usage: nextUsage,
396
+ content: event.content.map((b): { type: 'text'; text: string } =>
397
+ (b === block ? { ...b, text: receipt } : b) as { type: 'text'; text: string }),
398
+ usage: mergeUsage(event.usage, output.usage),
370
399
  };
371
400
  }
372
401
 
@@ -376,9 +405,8 @@ async function logReducerAttempt(
376
405
  epoch: number,
377
406
  record: Record<string, unknown>,
378
407
  ): Promise<void> {
379
- const logPath = efficiencyLogPath(sessionRoot, 'reducer');
380
408
  try {
381
- await appendEfficiencyLog(logPath, {
409
+ await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'reducer'), {
382
410
  schema: 'pier-efficiency/1',
383
411
  mechanism: 'evidencePreservingReducer',
384
412
  ts: new Date().toISOString(),
@@ -387,6 +415,5 @@ async function logReducerAttempt(
387
415
  ...record,
388
416
  });
389
417
  } catch {
390
- /* ignore logging error */
391
418
  }
392
419
  }