pi-pier 0.3.0 → 0.3.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 (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  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 +8 -12
  9. package/src/compact-coordinator.ts +278 -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/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. 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,16 @@
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 { containsLikelySecret, FAILURE_SIGNAL } from './reducer-core.ts';
17
12
 
18
- // ---------------------------------------------------------------------------
19
- // Request/response shapes (closed set)
20
- // ---------------------------------------------------------------------------
13
+ /* ── Request/response shapes (closed set) ── */
21
14
 
22
15
  export type JevQuestion =
23
16
  | { readonly type: 'noul'; readonly instructions: string; readonly criteria?: { readonly true?: string; readonly false?: string } }
@@ -56,6 +49,18 @@ function unitNumber(v: unknown): number | undefined {
56
49
  return n !== undefined && n >= 0 && n <= 1 ? n : undefined;
57
50
  }
58
51
 
52
+ type AnswerOf<T extends JevAnswer['type']> = Extract<JevAnswer, { type: T }>;
53
+
54
+ /** A missing or wrongly-shaped answer counts as unanswered, never as a verdict (RFC fail-open). */
55
+ function answerOf<T extends JevAnswer['type']>(
56
+ answers: Record<string, JevAnswer>,
57
+ id: string,
58
+ type: T,
59
+ ): AnswerOf<T> | undefined {
60
+ const answer = answers[id];
61
+ return answer?.type === type ? answer as AnswerOf<T> : undefined;
62
+ }
63
+
59
64
  function parseProbabilities(v: unknown): Record<string, number> | undefined {
60
65
  if (!isPlainObject(v)) return undefined;
61
66
  const out: Record<string, number> = {};
@@ -67,11 +72,8 @@ function parseProbabilities(v: unknown): Record<string, number> | undefined {
67
72
  return out;
68
73
  }
69
74
 
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
- */
75
+ /** Strict validation of one response against the question ids we sent: extra ids are ignored, and
76
+ * every expected id must be present and well-formed or the call counts as unanswered (fail-open). */
75
77
  export function parseJevAnswers(raw: unknown, expectedIds: readonly string[]): JevParsed {
76
78
  if (!isPlainObject(raw)) return { ok: false, reason: 'response-not-object' };
77
79
  const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model : undefined;
@@ -120,9 +122,7 @@ export function parseJevAnswers(raw: unknown, expectedIds: readonly string[]): J
120
122
  return { ok: true, answers, model, usage: { inputTokens, outputTokens } };
121
123
  }
122
124
 
123
- // ---------------------------------------------------------------------------
124
- // P0-1: EPR diagnostic-command gate
125
- // ---------------------------------------------------------------------------
125
+ /* ── P0-1: EPR diagnostic-command gate ── */
126
126
 
127
127
  export const DIAGNOSTIC_OUTPUT_MIN_NOUL = 0.6;
128
128
 
@@ -152,18 +152,16 @@ export interface DiagnosticGateVerdict {
152
152
  readonly hit: boolean;
153
153
  readonly reason: string;
154
154
  readonly confidence: number | null;
155
- /** Selected cmd_kind option (telemetry); null when the answer was malformed. */
156
155
  readonly choice: string | null;
157
- /** diagnostic_output noul value (telemetry); null when the answer was malformed. */
158
156
  readonly noul: number | null;
159
157
  }
160
158
  export function evaluateDiagnosticGate(
161
159
  answers: Record<string, JevAnswer>,
162
160
  minConfidence: number,
163
161
  ): 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') {
162
+ const kind = answerOf(answers, 'cmd_kind', 'choice');
163
+ const output = answerOf(answers, 'diagnostic_output', 'noul');
164
+ if (kind === undefined || output === undefined) {
167
165
  return { hit: false, reason: 'missing-answer', confidence: null, choice: null, noul: null };
168
166
  }
169
167
  if (kind.choice !== 'build_test_run') {
@@ -178,17 +176,18 @@ export function evaluateDiagnosticGate(
178
176
  return { hit: true, reason: 'hit', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
179
177
  }
180
178
 
181
- // ---------------------------------------------------------------------------
182
- // P0-2: settlement-notice relevance ranking
183
- // ---------------------------------------------------------------------------
179
+ /** Flip semantics: jev judges first, the regex list decides only when jev produced no usable answer
180
+ * (failure, malformed, below gate). A confident rejection outranks a regex match. */
181
+ export function isDiagnosticGateUnanswered(verdict: DiagnosticGateVerdict): boolean {
182
+ return verdict.reason === 'missing-answer' || verdict.reason === 'low-confidence';
183
+ }
184
184
 
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
- */
185
+ /* ── P0-2: settlement-notice relevance ranking ── */
186
+
187
+ /** Stricter than the global default: settlement text is mostly Chinese while English is jev's primary
188
+ * training language, so the initial gate starts at 0.7 and calibrates from jev.jsonl. */
190
189
  export const NOTICE_RANK_MIN_CONFIDENCE = 0.7;
191
- export const RANK_SCORE_WEIGHT = 0.6;
190
+ const RANK_SCORE_WEIGHT = 0.6;
192
191
 
193
192
  const RANK_LEVELS = [
194
193
  'Routine completion; the master agent can check it later',
@@ -202,9 +201,7 @@ export function noticeRankRequest(input: {
202
201
  }): JevRequest {
203
202
  const questions: Record<string, JevQuestion> = {};
204
203
  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).
204
+ // Question keys are NOT sent to the model: identical instructions would collapse every notice into one.
208
205
  questions[`notice_${i}_rank`] = {
209
206
  type: 'score',
210
207
  instructions: `How urgently does the master agent need to see settlements[${i}] right now, given in_progress_todos?`,
@@ -224,23 +221,15 @@ export function noticeRankRequest(input: {
224
221
  };
225
222
  }
226
223
 
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;
224
+ /** A settlement whose fail-noul clears this is pinned to the front regardless of relevance score:
225
+ * score noise on CJK reordered the same failure notice beyond the cap of shown notices. */
226
+ const NOTICE_FAIL_PIN_THRESHOLD = 0.7;
235
227
 
236
228
  /**
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.
229
+ * Display order: pinned failures first, then descending composed relevance (stable on ties -> arrival
230
+ * order); the per-item confidence gate sinks an item to the routine bucket instead of voiding the
231
+ * batch, and all items gated -> null. The fail pin ignores the score gate: noul answers carry no
232
+ * confidence field and failure visibility is the safety property.
244
233
  */
245
234
  export function composeNoticeRanking(
246
235
  noticeCount: number,
@@ -250,11 +239,9 @@ export function composeNoticeRanking(
250
239
  const entries: Array<{ pin: number; value: number; index: number }> = [];
251
240
  let answered = 0;
252
241
  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
- }
242
+ const rank = answerOf(answers, `notice_${i}_rank`, 'score');
243
+ const fail = answerOf(answers, `notice_${i}_fail`, 'noul');
244
+ if (rank === undefined || fail === undefined) return null;
258
245
  const gated = rank.confidence < minConfidence;
259
246
  if (!gated) answered++;
260
247
  const normalizedScore = rank.score / Math.max(1, RANK_LEVELS.length - 1);
@@ -269,11 +256,9 @@ export function composeNoticeRanking(
269
256
  return entries.map((entry) => entry.index);
270
257
  }
271
258
 
272
- // ---------------------------------------------------------------------------
273
- // P0-3: OBS excerpt window candidates (head/tail halves miss the middle)
274
- // ---------------------------------------------------------------------------
259
+ /* ── P0-3: OBS excerpt window candidates (head/tail halves miss the middle) ── */
275
260
 
276
- export type ExcerptWindowId = 'first_signal' | 'densest';
261
+ export type ExcerptWindowId = 'first_signal' | 'densest' | 'mid';
277
262
 
278
263
  export interface ExcerptWindow {
279
264
  readonly id: ExcerptWindowId;
@@ -306,11 +291,9 @@ function buildLineWindow(lines: readonly string[], start: number, budgetBytes: n
306
291
  return { text: selected.join('\n'), bytes, signalCount };
307
292
  }
308
293
 
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
- */
294
+ /** Code-owned middle-window candidates for a packed observation: failure-signal windows (first hit,
295
+ * densest cluster) when present, plus a plain mid-output window ALWAYS offered, so jev judges every
296
+ * packed output — including logs whose failure lines the English FAILURE_SIGNAL regex cannot see. */
314
297
  export function buildExcerptWindows(text: string, halfBudgetBytes: number): ExcerptWindow[] {
315
298
  if (halfBudgetBytes <= 0) return [];
316
299
  const lines = text.split('\n');
@@ -318,28 +301,34 @@ export function buildExcerptWindows(text: string, halfBudgetBytes: number): Exce
318
301
  for (let i = 0; i < lines.length; i++) {
319
302
  if (FAILURE_SIGNAL.test(lines[i]!)) signalLines.add(i);
320
303
  }
321
- if (signalLines.size === 0) return [];
322
304
 
323
305
  const out: ExcerptWindow[] = [];
324
306
  const seen = new Set<string>();
325
307
 
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
- }
308
+ if (signalLines.size > 0) {
309
+ const first = Math.min(...signalLines);
310
+ const firstWindow = buildLineWindow(lines, Math.max(0, first - 2), halfBudgetBytes, signalLines);
311
+ if (firstWindow !== null && !seen.has(firstWindow.text)) {
312
+ seen.add(firstWindow.text);
313
+ out.push({ id: 'first_signal', label: 'first failure-signal region', text: firstWindow.text });
314
+ }
332
315
 
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;
316
+ let best: LineWindow | null = null;
317
+ for (const anchor of signalLines) {
318
+ const window = buildLineWindow(lines, anchor, halfBudgetBytes, signalLines);
319
+ if (window === null) continue;
320
+ if (best === null || window.signalCount > best.signalCount) best = window;
321
+ }
322
+ if (best !== null && !seen.has(best.text)) {
323
+ seen.add(best.text);
324
+ out.push({ id: 'densest', label: 'densest failure-signal region', text: best.text });
325
+ }
339
326
  }
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 });
327
+
328
+ const midAnchor = Math.min(Math.floor(lines.length / 2), Math.max(0, lines.length - 1));
329
+ const midWindow = buildLineWindow(lines, midAnchor, halfBudgetBytes, signalLines);
330
+ if (midWindow !== null && !seen.has(midWindow.text)) {
331
+ out.push({ id: 'mid', label: 'middle region of the output', text: midWindow.text });
343
332
  }
344
333
  return out;
345
334
  }
@@ -374,8 +363,69 @@ export function evaluateExcerptPick(
374
363
  answers: Record<string, JevAnswer>,
375
364
  minConfidence: number,
376
365
  ): ExcerptWindowId | null {
377
- const picked = answers.window;
378
- if (picked === undefined || picked.type !== 'choice') return null;
366
+ const picked = answerOf(answers, 'window', 'choice');
367
+ if (picked === undefined) return null;
379
368
  if (picked.confidence < minConfidence) return null;
380
- return picked.choice === 'first_signal' || picked.choice === 'densest' ? picked.choice : null;
369
+ return picked.choice === 'first_signal' || picked.choice === 'densest' || picked.choice === 'mid'
370
+ ? picked.choice
371
+ : null;
372
+ }
373
+
374
+ /** Local privacy gate for the excerpt-pick request: its state is sent for EVERY packed output — a path
375
+ * the EPR secret gate never covers — so any credential-shaped text keeps the whole request local. */
376
+ export function excerptAskIsSafe(
377
+ middleWindows: readonly ExcerptWindow[],
378
+ headExcerpt: string,
379
+ tailExcerpt: string,
380
+ ): boolean {
381
+ const parts = [headExcerpt, tailExcerpt, ...middleWindows.map((w) => w.text)];
382
+ return !parts.some((part) => containsLikelySecret(part));
383
+ }
384
+
385
+ /* ── P0-4: settlement attribution check ─────────────────────────── */
386
+
387
+ /** Noul answers carry no confidence field (unlike choice/score), so this use case gates on the noul
388
+ * probabilities themselves via dedicated constants, like DIAGNOSTIC_OUTPUT_MIN_NOUL. */
389
+ const SETTLE_MATCH_MIN_NOUL = 0.6;
390
+ const SETTLE_FINAL_MIN_NOUL = 0.6;
391
+
392
+ export type SettleNullVerdict = 'silent' | 'attribution-suspect' | 'extraction-failed';
393
+
394
+ export function settleVerdictRequest(input: { description: string; tail: string }): JevRequest {
395
+ return {
396
+ state: { task: input.description, transcriptTail: input.tail },
397
+ questions: {
398
+ tail_matches_task: {
399
+ type: 'noul',
400
+ 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.',
401
+ criteria: {
402
+ true: 'The tail discusses, works on, or reports on the described task',
403
+ false: 'The tail is about an unrelated task or a different conversation',
404
+ },
405
+ },
406
+ tail_has_final_answer: {
407
+ type: 'noul',
408
+ instructions: 'Does the transcript tail end with (or contain) a complete final answer or report for the task, rather than only intermediate tool exchanges?',
409
+ criteria: {
410
+ true: 'A complete final answer/report is present',
411
+ false: 'Only intermediate steps, questions, or tool exchanges — no final answer',
412
+ },
413
+ },
414
+ },
415
+ };
416
+ }
417
+ /** Compose the null-closing verdict. null = unanswered (jev off / failed / wrong shapes): callers
418
+ * fall back to deterministic signals and the legacy wording, so jev-off stays byte-identical. */
419
+ export function evaluateSettleVerdict(answers: Record<string, JevAnswer>): SettleNullVerdict | null {
420
+ const match = answerOf(answers, 'tail_matches_task', 'noul');
421
+ const fin = answerOf(answers, 'tail_has_final_answer', 'noul');
422
+ if (match === undefined || fin === undefined) return null;
423
+ if (match.noul < SETTLE_MATCH_MIN_NOUL) return 'attribution-suspect';
424
+ if (fin.noul >= SETTLE_FINAL_MIN_NOUL) return 'extraction-failed';
425
+ return 'silent';
426
+ }
427
+
428
+ /** Privacy gate shared with EPR: never ship credential-shaped tails off-host. */
429
+ export function settleAskIsSafe(tail: string): boolean {
430
+ return !containsLikelySecret(tail);
381
431
  }
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;