pi-pier 0.2.7 → 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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -0,0 +1,141 @@
1
+ /**
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.
5
+ *
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.
8
+ */
9
+ import { sha256Hex } from './observation-core.ts';
10
+ import { appendEfficiencyLog, efficiencyLogPath } from './efficiency-store.ts';
11
+ import { parseJevAnswers, type JevAnswer, type JevQuestion, type JevUsage } from './jev-core.ts';
12
+ import type { JevConfig } from './efficiency-config-core.ts';
13
+
14
+ const JEV_DEFAULT_BASE_URL = 'https://api.typesafe.ai';
15
+
16
+ export interface JevAskMeta {
17
+ /** Stable question-set id for telemetry rows (e.g. 'epr-diagnostic-gate'). */
18
+ questionId: string;
19
+ /** Total-budget override in ms; defaults to the config timeoutMs. */
20
+ timeoutMs?: number;
21
+ sessionId?: string;
22
+ /** Site-specific static fields for telemetry; must never contain request bodies. */
23
+ extra?: Record<string, unknown>;
24
+ /**
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.
27
+ */
28
+ enrich?: (outcome: { ok: boolean; answers: Record<string, JevAnswer> | null }) => Record<string, unknown>;
29
+ }
30
+
31
+ export type JevAskResult =
32
+ | { readonly ok: true; readonly answers: Record<string, JevAnswer>; readonly model: string; readonly usage: JevUsage; readonly latencyMs: number }
33
+ | { readonly ok: false; readonly reason: string; readonly latencyMs: number };
34
+
35
+ export interface JevRuntimeDeps {
36
+ fetchImpl?: typeof fetch;
37
+ now?: () => number;
38
+ /** Session root for jev.jsonl; telemetry is skipped when absent. */
39
+ getSessionRoot?: () => string | null;
40
+ /** Session id fallback for telemetry rows; sites that know it pass meta.sessionId instead. */
41
+ getSessionId?: () => string | null | undefined;
42
+ }
43
+
44
+ export interface JevRuntime {
45
+ readonly available: boolean;
46
+ ask(request: { state: unknown; questions: Record<string, JevQuestion> }, meta: JevAskMeta): Promise<JevAskResult>;
47
+ }
48
+
49
+ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDeps = {}): JevRuntime {
50
+ const doFetch = deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
51
+ const now = deps.now ?? Date.now;
52
+
53
+ async function logAsk(
54
+ config: JevConfig,
55
+ meta: JevAskMeta,
56
+ outcome: { ok: true; model: string; usage: JevUsage } | { ok: false; reason: string },
57
+ latencyMs: number,
58
+ state: unknown,
59
+ enriched: Record<string, unknown>,
60
+ ): Promise<void> {
61
+ if (!config.logEnabled) return;
62
+ const sessionRoot = deps.getSessionRoot?.() ?? null;
63
+ if (!sessionRoot) return;
64
+ const serialized = JSON.stringify(state) ?? '';
65
+ const record: Record<string, unknown> = {
66
+ schema: 'pier-efficiency/1',
67
+ mechanism: 'jev',
68
+ sessionId: meta.sessionId ?? deps.getSessionId?.() ?? 'unknown',
69
+ ts: new Date(now()).toISOString(),
70
+ questionId: meta.questionId,
71
+ model: outcome.ok ? outcome.model : config.model,
72
+ latencyMs,
73
+ verdict: outcome.ok ? 'answered' : 'fallback',
74
+ fallback: !outcome.ok,
75
+ reason: outcome.ok ? null : outcome.reason,
76
+ stateBytes: Buffer.byteLength(serialized, 'utf8'),
77
+ stateHash: sha256Hex(serialized),
78
+ ...(outcome.ok ? { usage: { input_tokens: outcome.usage.inputTokens, output_tokens: outcome.usage.outputTokens } } : {}),
79
+ ...(meta.extra ?? {}),
80
+ ...enriched,
81
+ };
82
+ try {
83
+ await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'jev'), record);
84
+ } catch {
85
+ /* Never surfaced to the caller. */
86
+ }
87
+ }
88
+
89
+ return {
90
+ // Getter: config reloads (session_start re-resolves from disk) propagate.
91
+ get available(): boolean {
92
+ const config = getConfig();
93
+ return config.enabled && Boolean(config.apiKey);
94
+ },
95
+ async ask(request, meta): Promise<JevAskResult> {
96
+ const startedAt = now();
97
+ const config = getConfig();
98
+ const fail = async (reason: string): Promise<JevAskResult> => {
99
+ const latencyMs = now() - startedAt;
100
+ const enriched = meta.enrich?.({ ok: false, answers: null }) ?? {};
101
+ await logAsk(config, meta, { ok: false, reason }, latencyMs, request.state, enriched);
102
+ return { ok: false, reason, latencyMs };
103
+ };
104
+ if (!config.enabled) return fail('disabled');
105
+ if (!config.apiKey) return fail('no-api-key');
106
+
107
+ const timeoutMs = meta.timeoutMs ?? config.timeoutMs;
108
+ const controller = new AbortController();
109
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
110
+ try {
111
+ const base = (config.baseUrl ?? JEV_DEFAULT_BASE_URL).replace(/\/+$/, '');
112
+ const response = await doFetch(`${base}/v1/systemone`, {
113
+ method: 'POST',
114
+ headers: {
115
+ Authorization: `Bearer ${config.apiKey}`,
116
+ 'Content-Type': 'application/json',
117
+ },
118
+ body: JSON.stringify({ model: config.model, state: request.state, questions: request.questions }),
119
+ signal: controller.signal,
120
+ });
121
+ if (!response.ok) {
122
+ // 429 (and everything else) fails open immediately: limits are
123
+ // dynamically adjusted upstream and every pier call is droppable.
124
+ return fail(response.status === 429 ? 'rate-limited' : `http-${response.status}`);
125
+ }
126
+ const raw: unknown = await response.json();
127
+ const parsed = parseJevAnswers(raw, Object.keys(request.questions));
128
+ if (!parsed.ok) return fail(parsed.reason);
129
+ const latencyMs = now() - startedAt;
130
+ const enriched = meta.enrich?.({ ok: true, answers: parsed.answers }) ?? {};
131
+ await logAsk(config, meta, { ok: true, model: parsed.model, usage: parsed.usage }, latencyMs, request.state, enriched);
132
+ return { ok: true, answers: parsed.answers, model: parsed.model, usage: parsed.usage, latencyMs };
133
+ } catch (error) {
134
+ const aborted = error instanceof Error && error.name === 'AbortError';
135
+ return fail(aborted ? 'timeout' : 'network-error');
136
+ } finally {
137
+ clearTimeout(timer);
138
+ }
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Jev decision layer — pure core (RFC docs/rfc-jev-integration.md).
3
+ *
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.
6
+ *
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.
10
+ */
11
+ import { containsLikelySecret, FAILURE_SIGNAL } from './reducer-core.ts';
12
+
13
+ /* ── Request/response shapes (closed set) ── */
14
+
15
+ export type JevQuestion =
16
+ | { readonly type: 'noul'; readonly instructions: string; readonly criteria?: { readonly true?: string; readonly false?: string } }
17
+ | { readonly type: 'choice'; readonly instructions: string; readonly criteria: Record<string, string> }
18
+ | { readonly type: 'score'; readonly instructions: string; readonly criteria: readonly string[] };
19
+
20
+ export interface JevUsage {
21
+ readonly inputTokens: number;
22
+ readonly outputTokens: number;
23
+ }
24
+
25
+ export type JevAnswer =
26
+ | { readonly type: 'noul'; readonly noul: number }
27
+ | { readonly type: 'choice'; readonly choice: string; readonly probabilities: Record<string, number>; readonly confidence: number }
28
+ | { readonly type: 'score'; readonly score: number; readonly probabilities: Record<string, number>; readonly confidence: number };
29
+
30
+ export interface JevRequest {
31
+ readonly state: unknown;
32
+ readonly questions: Record<string, JevQuestion>;
33
+ }
34
+
35
+ export type JevParsed =
36
+ | { readonly ok: true; readonly answers: Record<string, JevAnswer>; readonly model: string; readonly usage: JevUsage }
37
+ | { readonly ok: false; readonly reason: string };
38
+
39
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
40
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
41
+ }
42
+
43
+ function finiteNumber(v: unknown): number | undefined {
44
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
45
+ }
46
+
47
+ function unitNumber(v: unknown): number | undefined {
48
+ const n = finiteNumber(v);
49
+ return n !== undefined && n >= 0 && n <= 1 ? n : undefined;
50
+ }
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
+
64
+ function parseProbabilities(v: unknown): Record<string, number> | undefined {
65
+ if (!isPlainObject(v)) return undefined;
66
+ const out: Record<string, number> = {};
67
+ for (const [k, raw] of Object.entries(v)) {
68
+ const n = finiteNumber(raw);
69
+ if (n === undefined) return undefined;
70
+ out[k] = n;
71
+ }
72
+ return out;
73
+ }
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). */
77
+ export function parseJevAnswers(raw: unknown, expectedIds: readonly string[]): JevParsed {
78
+ if (!isPlainObject(raw)) return { ok: false, reason: 'response-not-object' };
79
+ const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model : undefined;
80
+ if (model === undefined) return { ok: false, reason: 'missing-model' };
81
+ const rawUsage = isPlainObject(raw.usage) ? raw.usage : undefined;
82
+ const inputTokens = finiteNumber(rawUsage?.input_tokens);
83
+ const outputTokens = finiteNumber(rawUsage?.output_tokens);
84
+ if (inputTokens === undefined || inputTokens < 0 || outputTokens === undefined || outputTokens < 0) {
85
+ return { ok: false, reason: 'bad-usage' };
86
+ }
87
+ const rawAnswers = isPlainObject(raw.answers) ? raw.answers : undefined;
88
+ if (rawAnswers === undefined) return { ok: false, reason: 'missing-answers' };
89
+
90
+ const answers: Record<string, JevAnswer> = {};
91
+ for (const id of expectedIds) {
92
+ const a = isPlainObject(rawAnswers[id]) ? rawAnswers[id] : undefined;
93
+ if (a === undefined) return { ok: false, reason: `missing-answer:${id}` };
94
+ if (a.type === 'noul') {
95
+ const noul = unitNumber(a.noul);
96
+ if (noul === undefined) return { ok: false, reason: `bad-noul:${id}` };
97
+ answers[id] = { type: 'noul', noul };
98
+ continue;
99
+ }
100
+ if (a.type === 'choice') {
101
+ const choice = typeof a.choice === 'string' ? a.choice : undefined;
102
+ const probabilities = parseProbabilities(a.probabilities);
103
+ const confidence = unitNumber(a.confidence);
104
+ if (choice === undefined || probabilities === undefined || confidence === undefined) {
105
+ return { ok: false, reason: `bad-choice:${id}` };
106
+ }
107
+ answers[id] = { type: 'choice', choice, probabilities, confidence };
108
+ continue;
109
+ }
110
+ if (a.type === 'score') {
111
+ const score = finiteNumber(a.score);
112
+ const probabilities = parseProbabilities(a.probabilities);
113
+ const confidence = unitNumber(a.confidence);
114
+ if (score === undefined || score < 0 || probabilities === undefined || confidence === undefined) {
115
+ return { ok: false, reason: `bad-score:${id}` };
116
+ }
117
+ answers[id] = { type: 'score', score, probabilities, confidence };
118
+ continue;
119
+ }
120
+ return { ok: false, reason: `unknown-answer-type:${id}` };
121
+ }
122
+ return { ok: true, answers, model, usage: { inputTokens, outputTokens } };
123
+ }
124
+
125
+ /* ── P0-1: EPR diagnostic-command gate ── */
126
+
127
+ export const DIAGNOSTIC_OUTPUT_MIN_NOUL = 0.6;
128
+
129
+ const DIAGNOSTIC_GATE_QUESTIONS: Record<string, JevQuestion> = {
130
+ cmd_kind: {
131
+ type: 'choice',
132
+ instructions: 'What kind of shell command is this?',
133
+ criteria: {
134
+ build_test_run: 'Runs a build, test, lint, or typecheck pipeline whose output is repetitive diagnostic log lines (pass/fail, errors, warnings)',
135
+ install_deps: 'Installs or updates dependencies (npm/pip/cargo fetch); output is progress bars and version lists',
136
+ server_watch: 'Starts a long-running server, watcher, or REPL that stays in the foreground',
137
+ source_inspection: 'Prints or searches source/config (cat, ls, grep, git log, diff)',
138
+ other: 'None of the above',
139
+ },
140
+ },
141
+ diagnostic_output: {
142
+ type: 'noul',
143
+ instructions: 'Will this command produce repetitive test/build diagnostic log output (rather than conversational or interactive output)?',
144
+ },
145
+ };
146
+
147
+ export function diagnosticGateRequest(command: string): JevRequest {
148
+ return { state: { command }, questions: DIAGNOSTIC_GATE_QUESTIONS };
149
+ }
150
+
151
+ export interface DiagnosticGateVerdict {
152
+ readonly hit: boolean;
153
+ readonly reason: string;
154
+ readonly confidence: number | null;
155
+ readonly choice: string | null;
156
+ readonly noul: number | null;
157
+ }
158
+ export function evaluateDiagnosticGate(
159
+ answers: Record<string, JevAnswer>,
160
+ minConfidence: number,
161
+ ): DiagnosticGateVerdict {
162
+ const kind = answerOf(answers, 'cmd_kind', 'choice');
163
+ const output = answerOf(answers, 'diagnostic_output', 'noul');
164
+ if (kind === undefined || output === undefined) {
165
+ return { hit: false, reason: 'missing-answer', confidence: null, choice: null, noul: null };
166
+ }
167
+ if (kind.choice !== 'build_test_run') {
168
+ return { hit: false, reason: `not-build-test:${kind.choice}`, confidence: kind.confidence, choice: kind.choice, noul: output.noul };
169
+ }
170
+ if (kind.confidence < minConfidence) {
171
+ return { hit: false, reason: 'low-confidence', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
172
+ }
173
+ if (output.noul < DIAGNOSTIC_OUTPUT_MIN_NOUL) {
174
+ return { hit: false, reason: 'non-diagnostic-output', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
175
+ }
176
+ return { hit: true, reason: 'hit', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
177
+ }
178
+
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
+
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. */
189
+ export const NOTICE_RANK_MIN_CONFIDENCE = 0.7;
190
+ const RANK_SCORE_WEIGHT = 0.6;
191
+
192
+ const RANK_LEVELS = [
193
+ 'Routine completion; the master agent can check it later',
194
+ 'The master agent should be aware; it affects planning',
195
+ 'Reports a failure, error, or blocked state needing immediate attention',
196
+ ] as const;
197
+
198
+ export function noticeRankRequest(input: {
199
+ inProgressTodos: readonly string[];
200
+ notices: readonly string[];
201
+ }): JevRequest {
202
+ const questions: Record<string, JevQuestion> = {};
203
+ for (let i = 0; i < input.notices.length; i++) {
204
+ // Question keys are NOT sent to the model: identical instructions would collapse every notice into one.
205
+ questions[`notice_${i}_rank`] = {
206
+ type: 'score',
207
+ instructions: `How urgently does the master agent need to see settlements[${i}] right now, given in_progress_todos?`,
208
+ criteria: RANK_LEVELS,
209
+ };
210
+ questions[`notice_${i}_fail`] = {
211
+ type: 'noul',
212
+ instructions: `Does settlements[${i}] report a failure, error, or blocked state?`,
213
+ };
214
+ }
215
+ return {
216
+ state: {
217
+ in_progress_todos: input.inProgressTodos,
218
+ settlements: input.notices,
219
+ },
220
+ questions,
221
+ };
222
+ }
223
+
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;
227
+
228
+ /**
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.
233
+ */
234
+ export function composeNoticeRanking(
235
+ noticeCount: number,
236
+ answers: Record<string, JevAnswer>,
237
+ minConfidence: number,
238
+ ): number[] | null {
239
+ const entries: Array<{ pin: number; value: number; index: number }> = [];
240
+ let answered = 0;
241
+ for (let i = 0; i < noticeCount; i++) {
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;
245
+ const gated = rank.confidence < minConfidence;
246
+ if (!gated) answered++;
247
+ const normalizedScore = rank.score / Math.max(1, RANK_LEVELS.length - 1);
248
+ entries.push({
249
+ pin: fail.noul >= NOTICE_FAIL_PIN_THRESHOLD ? 1 : 0,
250
+ value: gated ? 0 : RANK_SCORE_WEIGHT * normalizedScore + (1 - RANK_SCORE_WEIGHT) * fail.noul,
251
+ index: i,
252
+ });
253
+ }
254
+ if (answered === 0) return null;
255
+ entries.sort((a, b) => (b.pin - a.pin) || (b.value - a.value) || (a.index - b.index));
256
+ return entries.map((entry) => entry.index);
257
+ }
258
+
259
+ /* ── P0-3: OBS excerpt window candidates (head/tail halves miss the middle) ── */
260
+
261
+ export type ExcerptWindowId = 'first_signal' | 'densest' | 'mid';
262
+
263
+ export interface ExcerptWindow {
264
+ readonly id: ExcerptWindowId;
265
+ readonly label: string;
266
+ readonly text: string;
267
+ }
268
+
269
+ interface LineWindow {
270
+ readonly text: string;
271
+ readonly bytes: number;
272
+ readonly signalCount: number;
273
+ }
274
+
275
+ function buildLineWindow(lines: readonly string[], start: number, budgetBytes: number, signalLines: ReadonlySet<number>): LineWindow | null {
276
+ const selected: string[] = [];
277
+ let bytes = 0;
278
+ let signalCount = 0;
279
+ for (let i = start; i < lines.length; i++) {
280
+ const line = lines[i]!;
281
+ const lineBytes = Buffer.byteLength(line, 'utf8') + 1; // +1 newline
282
+ if (bytes + lineBytes > budgetBytes) {
283
+ if (selected.length === 0) return null; // anchor line alone exceeds the budget
284
+ break;
285
+ }
286
+ selected.push(line);
287
+ bytes += lineBytes;
288
+ if (signalLines.has(i)) signalCount++;
289
+ }
290
+ if (selected.length === 0) return null;
291
+ return { text: selected.join('\n'), bytes, signalCount };
292
+ }
293
+
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. */
297
+ export function buildExcerptWindows(text: string, halfBudgetBytes: number): ExcerptWindow[] {
298
+ if (halfBudgetBytes <= 0) return [];
299
+ const lines = text.split('\n');
300
+ const signalLines = new Set<number>();
301
+ for (let i = 0; i < lines.length; i++) {
302
+ if (FAILURE_SIGNAL.test(lines[i]!)) signalLines.add(i);
303
+ }
304
+
305
+ const out: ExcerptWindow[] = [];
306
+ const seen = new Set<string>();
307
+
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
+ }
315
+
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
+ }
326
+ }
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 });
332
+ }
333
+ return out;
334
+ }
335
+
336
+ export function excerptPickRequest(
337
+ middleWindows: readonly ExcerptWindow[],
338
+ headExcerpt: string,
339
+ tailExcerpt: string,
340
+ ): JevRequest | null {
341
+ if (middleWindows.length === 0) return null;
342
+ const criteria: Record<string, string> = {
343
+ keep_head_tail: 'The existing head and tail excerpts are already the most informative; keep them',
344
+ };
345
+ const excerpts: Record<string, string> = { head_excerpt: headExcerpt, tail_excerpt: tailExcerpt };
346
+ for (const window of middleWindows) {
347
+ criteria[window.id] = `${window.label}: excerpt from the middle of the output`;
348
+ excerpts[window.id] = window.text;
349
+ }
350
+ return {
351
+ state: { excerpts },
352
+ questions: {
353
+ window: {
354
+ type: 'choice',
355
+ instructions: 'A large tool output was replaced by a placeholder keeping two short excerpts. Which excerpt set would most help a coding agent locate the problem without recalling the full text?',
356
+ criteria,
357
+ },
358
+ },
359
+ };
360
+ }
361
+
362
+ export function evaluateExcerptPick(
363
+ answers: Record<string, JevAnswer>,
364
+ minConfidence: number,
365
+ ): ExcerptWindowId | null {
366
+ const picked = answerOf(answers, 'window', 'choice');
367
+ if (picked === undefined) return null;
368
+ if (picked.confidence < minConfidence) return 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);
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;