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
package/src/jev-client.ts CHANGED
@@ -1,23 +1,17 @@
1
1
  /**
2
- * Jev decision layer — direct HTTP client (RFC docs/rfc-jev-integration.md §5).
2
+ * Jev decision layer — direct HTTP client (RFC docs/rfc-jev-integration.md §5). Not the official SDK
3
+ * (@typesafe-ai/sdk): its retry machinery has no total-latency budget (timeouts retried by default,
4
+ * Retry-After honored up to 60s), which is incompatible with pier's fail-open budgets.
3
5
  *
4
- * Why not the official SDK (@typesafe-ai/sdk v0.6.0): its retry machinery has
5
- * no total-latency budget (DEFAULT_TIMEOUT_MS is per attempt, apiTimeoutError
6
- * retries timeouts by default, Retry-After honored up to 60s — worst case
7
- * >80s), which is incompatible with pier's fail-open budgets. The call surface
8
- * here is one endpoint and three question types, and pi-pier ships as an npm
9
- * package where a zero-dependency transport wins. Payload is pinned to the
10
- * versioned model id; see the RFC for re-evaluation triggers.
11
- *
12
- * Every ask() resolves (never throws): errors, timeouts, 429s and low
13
- * confidence at the call site all mean " unanswered" -> caller fails open.
6
+ * Every ask() resolves (never throws): errors, timeouts, 429s and low confidence at the call site
7
+ * all mean unanswered, and the caller fails open.
14
8
  */
15
9
  import { sha256Hex } from './observation-core.ts';
16
10
  import { appendEfficiencyLog, efficiencyLogPath } from './efficiency-store.ts';
17
11
  import { parseJevAnswers, type JevAnswer, type JevQuestion, type JevUsage } from './jev-core.ts';
18
12
  import type { JevConfig } from './efficiency-config-core.ts';
19
13
 
20
- export const JEV_DEFAULT_BASE_URL = 'https://api.typesafe.ai';
14
+ const JEV_DEFAULT_BASE_URL = 'https://api.typesafe.ai';
21
15
 
22
16
  export interface JevAskMeta {
23
17
  /** Stable question-set id for telemetry rows (e.g. 'epr-diagnostic-gate'). */
@@ -28,9 +22,8 @@ export interface JevAskMeta {
28
22
  /** Site-specific static fields for telemetry; must never contain request bodies. */
29
23
  extra?: Record<string, unknown>;
30
24
  /**
31
- * Post-answer decision fields for telemetry (choices/scores/final verdicts —
32
- * the data later threshold tuning needs; still never request bodies).
33
- * Called exactly once per ask, success or failure.
25
+ * Post-answer decision fields for telemetry (choices/scores/final verdicts — what threshold tuning
26
+ * needs; still never request bodies). Called exactly once per ask.
34
27
  */
35
28
  enrich?: (outcome: { ok: boolean; answers: Record<string, JevAnswer> | null }) => Record<string, unknown>;
36
29
  }
@@ -44,6 +37,8 @@ export interface JevRuntimeDeps {
44
37
  now?: () => number;
45
38
  /** Session root for jev.jsonl; telemetry is skipped when absent. */
46
39
  getSessionRoot?: () => string | null;
40
+ /** Session id fallback for telemetry rows; sites that know it pass meta.sessionId instead. */
41
+ getSessionId?: () => string | null | undefined;
47
42
  }
48
43
 
49
44
  export interface JevRuntime {
@@ -70,8 +65,8 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
70
65
  const record: Record<string, unknown> = {
71
66
  schema: 'pier-efficiency/1',
72
67
  mechanism: 'jev',
68
+ sessionId: meta.sessionId ?? deps.getSessionId?.() ?? 'unknown',
73
69
  ts: new Date(now()).toISOString(),
74
- sessionId: meta.sessionId ?? 'unknown',
75
70
  questionId: meta.questionId,
76
71
  model: outcome.ok ? outcome.model : config.model,
77
72
  latencyMs,
@@ -87,7 +82,7 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
87
82
  try {
88
83
  await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'jev'), record);
89
84
  } catch {
90
- // Telemetry is best effort; never surface logging failures.
85
+ /* Never surfaced to the caller. */
91
86
  }
92
87
  }
93
88
 
package/src/jev-core.ts CHANGED
@@ -1,23 +1,17 @@
1
1
  /**
2
2
  * Jev decision layer — pure core (RFC docs/rfc-jev-integration.md).
3
3
  *
4
- * Question/answer shapes mirror the closed set of POST /v1/systemone
5
- * (https://docs.typesafe.ai/api): noul / choice / score. No I/O here; the HTTP
6
- * client + jev.jsonl telemetry live in jev-client.ts.
4
+ * Question/answer shapes mirror the closed set of POST /v1/systemone (https://docs.typesafe.ai/api):
5
+ * noul / choice / score. No I/O here; the HTTP client and jev.jsonl telemetry live in jev-client.ts.
7
6
  *
8
- * Design rules from the RFC:
9
- * - arithmetic, counting and thresholds stay in code (jaggedness #2/#3);
10
- * - state is minimal — only the fields each question needs (jaggedness #5);
11
- * - instructions are English (models page: English is the primary training
12
- * language; CJK is handled but weaker, so P0-2 runs a stricter gate);
13
- * - any answer below its confidence gate counts as unanswered -> caller
14
- * fails open to the existing heuristic.
7
+ * RFC design rules: arithmetic, counting and thresholds stay in code; state carries only what each
8
+ * question needs; instructions are English (CJK is weaker, hence the stricter P0-2 gate); and any
9
+ * answer below its confidence gate counts as unanswered so callers fail open.
15
10
  */
16
- import { FAILURE_SIGNAL } from './reducer-core.ts';
11
+ import { isPlainObject } from './guards.ts';
12
+ import { containsLikelySecret, FAILURE_SIGNAL } from './reducer-core.ts';
17
13
 
18
- // ---------------------------------------------------------------------------
19
- // Request/response shapes (closed set)
20
- // ---------------------------------------------------------------------------
14
+ /* ── Request/response shapes (closed set) ── */
21
15
 
22
16
  export type JevQuestion =
23
17
  | { readonly type: 'noul'; readonly instructions: string; readonly criteria?: { readonly true?: string; readonly false?: string } }
@@ -43,9 +37,6 @@ export type JevParsed =
43
37
  | { readonly ok: true; readonly answers: Record<string, JevAnswer>; readonly model: string; readonly usage: JevUsage }
44
38
  | { readonly ok: false; readonly reason: string };
45
39
 
46
- function isPlainObject(v: unknown): v is Record<string, unknown> {
47
- return typeof v === 'object' && v !== null && !Array.isArray(v);
48
- }
49
40
 
50
41
  function finiteNumber(v: unknown): number | undefined {
51
42
  return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
@@ -56,6 +47,18 @@ function unitNumber(v: unknown): number | undefined {
56
47
  return n !== undefined && n >= 0 && n <= 1 ? n : undefined;
57
48
  }
58
49
 
50
+ type AnswerOf<T extends JevAnswer['type']> = Extract<JevAnswer, { type: T }>;
51
+
52
+ /** A missing or wrongly-shaped answer counts as unanswered, never as a verdict (RFC fail-open). */
53
+ function answerOf<T extends JevAnswer['type']>(
54
+ answers: Record<string, JevAnswer>,
55
+ id: string,
56
+ type: T,
57
+ ): AnswerOf<T> | undefined {
58
+ const answer = answers[id];
59
+ return answer?.type === type ? answer as AnswerOf<T> : undefined;
60
+ }
61
+
59
62
  function parseProbabilities(v: unknown): Record<string, number> | undefined {
60
63
  if (!isPlainObject(v)) return undefined;
61
64
  const out: Record<string, number> = {};
@@ -67,11 +70,8 @@ function parseProbabilities(v: unknown): Record<string, number> | undefined {
67
70
  return out;
68
71
  }
69
72
 
70
- /**
71
- * Strict validation of one response against the question ids we sent.
72
- * Extra answer ids are ignored; every expected id must be present and
73
- * well-formed or the whole call counts as unanswered (fail-open).
74
- */
73
+ /** Strict validation of one response against the question ids we sent: extra ids are ignored, and
74
+ * every expected id must be present and well-formed or the call counts as unanswered (fail-open). */
75
75
  export function parseJevAnswers(raw: unknown, expectedIds: readonly string[]): JevParsed {
76
76
  if (!isPlainObject(raw)) return { ok: false, reason: 'response-not-object' };
77
77
  const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model : undefined;
@@ -120,9 +120,7 @@ export function parseJevAnswers(raw: unknown, expectedIds: readonly string[]): J
120
120
  return { ok: true, answers, model, usage: { inputTokens, outputTokens } };
121
121
  }
122
122
 
123
- // ---------------------------------------------------------------------------
124
- // P0-1: EPR diagnostic-command gate
125
- // ---------------------------------------------------------------------------
123
+ /* ── P0-1: EPR diagnostic-command gate ── */
126
124
 
127
125
  export const DIAGNOSTIC_OUTPUT_MIN_NOUL = 0.6;
128
126
 
@@ -152,18 +150,16 @@ export interface DiagnosticGateVerdict {
152
150
  readonly hit: boolean;
153
151
  readonly reason: string;
154
152
  readonly confidence: number | null;
155
- /** Selected cmd_kind option (telemetry); null when the answer was malformed. */
156
153
  readonly choice: string | null;
157
- /** diagnostic_output noul value (telemetry); null when the answer was malformed. */
158
154
  readonly noul: number | null;
159
155
  }
160
156
  export function evaluateDiagnosticGate(
161
157
  answers: Record<string, JevAnswer>,
162
158
  minConfidence: number,
163
159
  ): DiagnosticGateVerdict {
164
- const kind = answers.cmd_kind;
165
- const output = answers.diagnostic_output;
166
- if (kind === undefined || output === undefined || kind.type !== 'choice' || output.type !== 'noul') {
160
+ const kind = answerOf(answers, 'cmd_kind', 'choice');
161
+ const output = answerOf(answers, 'diagnostic_output', 'noul');
162
+ if (kind === undefined || output === undefined) {
167
163
  return { hit: false, reason: 'missing-answer', confidence: null, choice: null, noul: null };
168
164
  }
169
165
  if (kind.choice !== 'build_test_run') {
@@ -178,17 +174,18 @@ export function evaluateDiagnosticGate(
178
174
  return { hit: true, reason: 'hit', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
179
175
  }
180
176
 
181
- // ---------------------------------------------------------------------------
182
- // P0-2: settlement-notice relevance ranking
183
- // ---------------------------------------------------------------------------
177
+ /** Flip semantics: jev judges first, the regex list decides only when jev produced no usable answer
178
+ * (failure, malformed, below gate). A confident rejection outranks a regex match. */
179
+ export function isDiagnosticGateUnanswered(verdict: DiagnosticGateVerdict): boolean {
180
+ return verdict.reason === 'missing-answer' || verdict.reason === 'low-confidence';
181
+ }
184
182
 
185
- /**
186
- * Stricter than the global minConfidence default: settlement text is mostly
187
- * Chinese while English is jev's primary training language (models page), so
188
- * the initial gate starts at 0.7 and calibrates from jev.jsonl.
189
- */
183
+ /* ── P0-2: settlement-notice relevance ranking ── */
184
+
185
+ /** Stricter than the global default: settlement text is mostly Chinese while English is jev's primary
186
+ * training language, so the initial gate starts at 0.7 and calibrates from jev.jsonl. */
190
187
  export const NOTICE_RANK_MIN_CONFIDENCE = 0.7;
191
- export const RANK_SCORE_WEIGHT = 0.6;
188
+ const RANK_SCORE_WEIGHT = 0.6;
192
189
 
193
190
  const RANK_LEVELS = [
194
191
  'Routine completion; the master agent can check it later',
@@ -202,9 +199,7 @@ export function noticeRankRequest(input: {
202
199
  }): JevRequest {
203
200
  const questions: Record<string, JevQuestion> = {};
204
201
  for (let i = 0; i < input.notices.length; i++) {
205
- // Question keys are NOT sent to the model, so identical instructions would
206
- // collapse every notice into the same question (observed live: 5x "1.98:0.97").
207
- // Each instruction must name its settlement by state index (cookbook pattern).
202
+ // Question keys are NOT sent to the model: identical instructions would collapse every notice into one.
208
203
  questions[`notice_${i}_rank`] = {
209
204
  type: 'score',
210
205
  instructions: `How urgently does the master agent need to see settlements[${i}] right now, given in_progress_todos?`,
@@ -224,23 +219,15 @@ export function noticeRankRequest(input: {
224
219
  };
225
220
  }
226
221
 
227
- /**
228
- * Failure-pin threshold: a settlement whose fail-noul clears this is forced to
229
- * the front regardless of its relevance score. Live calibration (2026-09-18):
230
- * two runs over the same Chinese batch reordered a failure notice from #2 to
231
- * #4 — beyond the 3-shown cap. Score noise on CJK must not be able to bury a
232
- * failure; the invariant lives in code, not in the model.
233
- */
234
- export const NOTICE_FAIL_PIN_THRESHOLD = 0.7;
222
+ /** A settlement whose fail-noul clears this is pinned to the front regardless of relevance score:
223
+ * score noise on CJK reordered the same failure notice beyond the cap of shown notices. */
224
+ const NOTICE_FAIL_PIN_THRESHOLD = 0.7;
235
225
 
236
226
  /**
237
- * Compose the display order: pinned failures first, then descending composed
238
- * relevance (stable on ties -> arrival order). Per-item confidence gate: an
239
- * item whose score answer is below the gate sinks to the routine bucket
240
- * instead of voiding the batch (one unsure routine notice must not reorder
241
- * everything back to arrival). All items gated -> null (nothing was answered).
242
- * The fail pin itself ignores the score gate: noul answers carry no confidence
243
- * field, and failure visibility is the safety property.
227
+ * Display order: pinned failures first, then descending composed relevance (stable on ties -> arrival
228
+ * order); the per-item confidence gate sinks an item to the routine bucket instead of voiding the
229
+ * batch, and all items gated -> null. The fail pin ignores the score gate: noul answers carry no
230
+ * confidence field and failure visibility is the safety property.
244
231
  */
245
232
  export function composeNoticeRanking(
246
233
  noticeCount: number,
@@ -250,11 +237,9 @@ export function composeNoticeRanking(
250
237
  const entries: Array<{ pin: number; value: number; index: number }> = [];
251
238
  let answered = 0;
252
239
  for (let i = 0; i < noticeCount; i++) {
253
- const rank = answers[`notice_${i}_rank`];
254
- const fail = answers[`notice_${i}_fail`];
255
- if (rank === undefined || fail === undefined || rank.type !== 'score' || fail.type !== 'noul') {
256
- return null;
257
- }
240
+ const rank = answerOf(answers, `notice_${i}_rank`, 'score');
241
+ const fail = answerOf(answers, `notice_${i}_fail`, 'noul');
242
+ if (rank === undefined || fail === undefined) return null;
258
243
  const gated = rank.confidence < minConfidence;
259
244
  if (!gated) answered++;
260
245
  const normalizedScore = rank.score / Math.max(1, RANK_LEVELS.length - 1);
@@ -269,11 +254,9 @@ export function composeNoticeRanking(
269
254
  return entries.map((entry) => entry.index);
270
255
  }
271
256
 
272
- // ---------------------------------------------------------------------------
273
- // P0-3: OBS excerpt window candidates (head/tail halves miss the middle)
274
- // ---------------------------------------------------------------------------
257
+ /* ── P0-3: OBS excerpt window candidates (head/tail halves miss the middle) ── */
275
258
 
276
- export type ExcerptWindowId = 'first_signal' | 'densest';
259
+ export type ExcerptWindowId = 'first_signal' | 'densest' | 'mid';
277
260
 
278
261
  export interface ExcerptWindow {
279
262
  readonly id: ExcerptWindowId;
@@ -306,11 +289,9 @@ function buildLineWindow(lines: readonly string[], start: number, budgetBytes: n
306
289
  return { text: selected.join('\n'), bytes, signalCount };
307
290
  }
308
291
 
309
- /**
310
- * Generate code-owned middle-window candidates for a packed observation:
311
- * the region around the first failure-signal line, and the densest cluster
312
- * of failure-signal lines within one half-budget. Deterministic, no model.
313
- */
292
+ /** Code-owned middle-window candidates for a packed observation: failure-signal windows (first hit,
293
+ * densest cluster) when present, plus a plain mid-output window ALWAYS offered, so jev judges every
294
+ * packed output — including logs whose failure lines the English FAILURE_SIGNAL regex cannot see. */
314
295
  export function buildExcerptWindows(text: string, halfBudgetBytes: number): ExcerptWindow[] {
315
296
  if (halfBudgetBytes <= 0) return [];
316
297
  const lines = text.split('\n');
@@ -318,28 +299,34 @@ export function buildExcerptWindows(text: string, halfBudgetBytes: number): Exce
318
299
  for (let i = 0; i < lines.length; i++) {
319
300
  if (FAILURE_SIGNAL.test(lines[i]!)) signalLines.add(i);
320
301
  }
321
- if (signalLines.size === 0) return [];
322
302
 
323
303
  const out: ExcerptWindow[] = [];
324
304
  const seen = new Set<string>();
325
305
 
326
- const first = Math.min(...signalLines);
327
- const firstWindow = buildLineWindow(lines, Math.max(0, first - 2), halfBudgetBytes, signalLines);
328
- if (firstWindow !== null && !seen.has(firstWindow.text)) {
329
- seen.add(firstWindow.text);
330
- out.push({ id: 'first_signal', label: 'first failure-signal region', text: firstWindow.text });
331
- }
306
+ if (signalLines.size > 0) {
307
+ const first = Math.min(...signalLines);
308
+ const firstWindow = buildLineWindow(lines, Math.max(0, first - 2), halfBudgetBytes, signalLines);
309
+ if (firstWindow !== null && !seen.has(firstWindow.text)) {
310
+ seen.add(firstWindow.text);
311
+ out.push({ id: 'first_signal', label: 'first failure-signal region', text: firstWindow.text });
312
+ }
332
313
 
333
- // Densest cluster: anchor at every signal line, extend greedily while in budget.
334
- let best: LineWindow | null = null;
335
- for (const anchor of signalLines) {
336
- const window = buildLineWindow(lines, anchor, halfBudgetBytes, signalLines);
337
- if (window === null) continue;
338
- if (best === null || window.signalCount > best.signalCount) best = window;
314
+ let best: LineWindow | null = null;
315
+ for (const anchor of signalLines) {
316
+ const window = buildLineWindow(lines, anchor, halfBudgetBytes, signalLines);
317
+ if (window === null) continue;
318
+ if (best === null || window.signalCount > best.signalCount) best = window;
319
+ }
320
+ if (best !== null && !seen.has(best.text)) {
321
+ seen.add(best.text);
322
+ out.push({ id: 'densest', label: 'densest failure-signal region', text: best.text });
323
+ }
339
324
  }
340
- if (best !== null && !seen.has(best.text)) {
341
- seen.add(best.text);
342
- out.push({ id: 'densest', label: 'densest failure-signal region', text: best.text });
325
+
326
+ const midAnchor = Math.min(Math.floor(lines.length / 2), Math.max(0, lines.length - 1));
327
+ const midWindow = buildLineWindow(lines, midAnchor, halfBudgetBytes, signalLines);
328
+ if (midWindow !== null && !seen.has(midWindow.text)) {
329
+ out.push({ id: 'mid', label: 'middle region of the output', text: midWindow.text });
343
330
  }
344
331
  return out;
345
332
  }
@@ -374,8 +361,69 @@ export function evaluateExcerptPick(
374
361
  answers: Record<string, JevAnswer>,
375
362
  minConfidence: number,
376
363
  ): ExcerptWindowId | null {
377
- const picked = answers.window;
378
- if (picked === undefined || picked.type !== 'choice') return null;
364
+ const picked = answerOf(answers, 'window', 'choice');
365
+ if (picked === undefined) return null;
379
366
  if (picked.confidence < minConfidence) return null;
380
- return picked.choice === 'first_signal' || picked.choice === 'densest' ? picked.choice : null;
367
+ return picked.choice === 'first_signal' || picked.choice === 'densest' || picked.choice === 'mid'
368
+ ? picked.choice
369
+ : null;
370
+ }
371
+
372
+ /** Local privacy gate for the excerpt-pick request: its state is sent for EVERY packed output — a path
373
+ * the EPR secret gate never covers — so any credential-shaped text keeps the whole request local. */
374
+ export function excerptAskIsSafe(
375
+ middleWindows: readonly ExcerptWindow[],
376
+ headExcerpt: string,
377
+ tailExcerpt: string,
378
+ ): boolean {
379
+ const parts = [headExcerpt, tailExcerpt, ...middleWindows.map((w) => w.text)];
380
+ return !parts.some((part) => containsLikelySecret(part));
381
+ }
382
+
383
+ /* ── P0-4: settlement attribution check ─────────────────────────── */
384
+
385
+ /** Noul answers carry no confidence field (unlike choice/score), so this use case gates on the noul
386
+ * probabilities themselves via dedicated constants, like DIAGNOSTIC_OUTPUT_MIN_NOUL. */
387
+ const SETTLE_MATCH_MIN_NOUL = 0.6;
388
+ const SETTLE_FINAL_MIN_NOUL = 0.6;
389
+
390
+ export type SettleNullVerdict = 'silent' | 'attribution-suspect' | 'extraction-failed';
391
+
392
+ export function settleVerdictRequest(input: { description: string; tail: string }): JevRequest {
393
+ return {
394
+ state: { task: input.description, transcriptTail: input.tail },
395
+ questions: {
396
+ tail_matches_task: {
397
+ type: 'noul',
398
+ instructions: 'Is this transcript tail from a session working on the described task? Answer false only when the tail is clearly about a different task or conversation.',
399
+ criteria: {
400
+ true: 'The tail discusses, works on, or reports on the described task',
401
+ false: 'The tail is about an unrelated task or a different conversation',
402
+ },
403
+ },
404
+ tail_has_final_answer: {
405
+ type: 'noul',
406
+ instructions: 'Does the transcript tail end with (or contain) a complete final answer or report for the task, rather than only intermediate tool exchanges?',
407
+ criteria: {
408
+ true: 'A complete final answer/report is present',
409
+ false: 'Only intermediate steps, questions, or tool exchanges — no final answer',
410
+ },
411
+ },
412
+ },
413
+ };
414
+ }
415
+ /** Compose the null-closing verdict. null = unanswered (jev off / failed / wrong shapes): callers
416
+ * fall back to deterministic signals and the legacy wording, so jev-off stays byte-identical. */
417
+ export function evaluateSettleVerdict(answers: Record<string, JevAnswer>): SettleNullVerdict | null {
418
+ const match = answerOf(answers, 'tail_matches_task', 'noul');
419
+ const fin = answerOf(answers, 'tail_has_final_answer', 'noul');
420
+ if (match === undefined || fin === undefined) return null;
421
+ if (match.noul < SETTLE_MATCH_MIN_NOUL) return 'attribution-suspect';
422
+ if (fin.noul >= SETTLE_FINAL_MIN_NOUL) return 'extraction-failed';
423
+ return 'silent';
424
+ }
425
+
426
+ /** Privacy gate shared with EPR: never ship credential-shaped tails off-host. */
427
+ export function settleAskIsSafe(tail: string): boolean {
428
+ return !containsLikelySecret(tail);
381
429
  }
package/src/ledger.ts CHANGED
@@ -1,24 +1,20 @@
1
1
  /**
2
2
  * Disposal ledger shared by HMR and session shutdown (D80⑤ + D79).
3
3
  *
4
- * HMR partial reload can skip old fiber effect disposers, so process-level resources
5
- * such as intervals, pipe servers, and watchers would leak without compensation.
6
- * Core modules register those resources by module key; reload disposes matching keys,
7
- * while session_shutdown disposes everything.
8
- *
9
- * D79 reuses this mechanism for pi-surface registration cleanup; logical keys need not
10
- * be file paths. The ledger stays dependency-free and connects through bootstrap/index.
4
+ * HMR partial reload can skip old fiber effect disposers, so process-level resources (intervals, pipe
5
+ * servers, watchers) would leak without compensation. Modules register those resources by module key;
6
+ * reload disposes matching keys, session_shutdown disposes everything. D79 reuses the same mechanism
7
+ * for pi-surface registration cleanup, where keys need not be file paths.
11
8
  */
12
9
 
13
10
  import { fileURLToPath } from 'node:url';
14
11
 
15
- /** Normalize file URLs and paths to comparable absolute keys for HMR matching. */
16
- export function normalizeModuleKey(spec: string): string {
12
+ function normalizeModuleKey(spec: string): string {
17
13
  let s = String(spec);
18
14
  try {
19
15
  if (s.startsWith('file://')) s = fileURLToPath(s);
20
16
  } catch {
21
- /* Keep malformed URLs unchanged so disposal can still use the caller's key. */
17
+ // Malformed URL: keep the caller's key so disposal still matches.
22
18
  }
23
19
  return s.replace(/\\/g, '/');
24
20
  }
@@ -27,7 +23,7 @@ export class DisposeLedger {
27
23
  /** LIFO order matches Cordis effect disposal semantics. */
28
24
  private order: Array<{ key: string; dispose: () => void }> = [];
29
25
 
30
- /** Register a resource and return cancellation so self-disposal cannot run twice. */
26
+ /** Register a resource; the returned cancellation makes self-disposal a no-op. */
31
27
  add(spec: string, dispose: () => void): () => void {
32
28
  const entry = { key: normalizeModuleKey(spec), dispose };
33
29
  this.order.push(entry);
@@ -37,27 +33,23 @@ export class DisposeLedger {
37
33
  };
38
34
  }
39
35
 
40
- /** HMR compensation disposes only matching keys in LIFO order. */
41
36
  disposeKey(spec: string | string[]): number {
42
- const keys = new Set((Array.isArray(spec) ? spec : [spec]).map(normalizeModuleKey));
37
+ const keys = (Array.isArray(spec) ? spec : [spec]).map(normalizeModuleKey);
43
38
  let n = 0;
44
39
  for (let i = this.order.length - 1; i >= 0; i--) {
45
- if (keys.has(this.order[i].key)) {
46
- const { dispose } = this.order[i];
47
- this.order.splice(i, 1);
48
- try { dispose(); } catch { /* Compensation failures are non-fatal. */ }
49
- n++;
50
- }
40
+ if (!keys.includes(this.order[i]!.key)) continue;
41
+ const { dispose } = this.order.splice(i, 1)[0]!;
42
+ try { dispose(); } catch { /* Compensation must never abort the reload. */ }
43
+ n++;
51
44
  }
52
45
  return n;
53
46
  }
54
47
 
55
- /** Dispose every entry in LIFO order during session_shutdown. */
56
48
  disposeAll(): number {
57
49
  let n = 0;
58
50
  while (this.order.length > 0) {
59
51
  const { dispose } = this.order.pop()!;
60
- try { dispose(); } catch { /* Compensation failures are non-fatal. */ }
52
+ try { dispose(); } catch { /* Reload/shutdown must survive a broken disposer. */ }
61
53
  n++;
62
54
  }
63
55
  return n;