omp-onlyne 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/decider.ts CHANGED
@@ -8,8 +8,8 @@ import {
8
8
  type Action,
9
9
  type DeliveryPlan,
10
10
  type Entry,
11
+ type JoinedThread,
11
12
  type Json,
12
- type MessageRef,
13
13
  type MsgInput,
14
14
  type NodeId,
15
15
  type Page,
@@ -19,26 +19,42 @@ import {
19
19
  OnlyneError,
20
20
  } from "@onlyne/lib";
21
21
  import { getLocale } from "@onlyne/node-host/i18n";
22
- import type { ContextRecord, OnlyneConfig } from "./types.ts";
23
- import { canAnswer, type ContextRegistry } from "./store.ts";
22
+ import { isTrustThread } from "./handshake.ts";
23
+ import type { ContextRecord, OnlyneConfig } from "@onlyne/node-host/types";
24
+ import { canAnswer, type ContextRegistry } from "@onlyne/node-host/store";
24
25
  import { text } from "./decider-text.ts";
26
+ import { ActionGuard, settleIntent, type OutboundIntent } from "./guard.ts";
27
+ import { continueThread, inheritedModes, resolvedModes, resourceAction, resourceFields, sendAction } from "./plan.ts";
28
+ import {
29
+ choice,
30
+ contextQuestions,
31
+ domainOpQuestions,
32
+ insertionQuestions,
33
+ intentQuestions,
34
+ needsAnswerQuestions,
35
+ originalQuestions,
36
+ planQuestions,
37
+ readScopeQuestions,
38
+ relationQuestions,
39
+ targetQuestions,
40
+ threadOpQuestions,
41
+ threadQuestions,
42
+ type Answers,
43
+ type Questions,
44
+ } from "./questions.ts";
45
+ import {
46
+ References,
47
+ knownPeers,
48
+ lastSegment,
49
+ threadKey,
50
+ type PeerRecord,
51
+ type ReplyReference,
52
+ } from "./resolve.ts";
25
53
 
26
54
  type Row = Record<string, unknown>;
27
55
 
28
- interface ChoiceQuestion {
29
- type: "choice";
30
- instructions: string;
31
- criteria: Record<string, string>;
32
- }
33
-
34
- type Questions = Record<string, ChoiceQuestion>;
35
-
36
- interface ChoiceAnswer {
37
- type?: string;
38
- choice?: string;
39
- }
40
-
41
- type Answers = Record<string, ChoiceAnswer | undefined>;
56
+ export type { Answers, Questions } from "./questions.ts";
57
+ export type { PeerRecord } from "./resolve.ts";
42
58
 
43
59
  /** A decision input that either carries its records or states why it has none. */
44
60
  type Source<T> = { available: true; value: T } | { available: false; reason: string };
@@ -57,14 +73,6 @@ function valueOf<T>(source: unknown): T | undefined {
57
73
  return row.value as T;
58
74
  }
59
75
 
60
- /** One discovered peer, as the library's discovery records it. */
61
- export interface PeerRecord {
62
- node_id: NodeId;
63
- base_url: string;
64
- instance_id: string;
65
- last_seen: string;
66
- }
67
-
68
76
  /** One execution context a receiving node publishes for a target-qualified delivery. */
69
77
  export interface ReceiverContext {
70
78
  alias: string;
@@ -106,6 +114,8 @@ export function currentDecision(): DecisionScope | undefined {
106
114
  return decisionScope.getStore();
107
115
  }
108
116
 
117
+ export { knownPeers, reuseThread } from "./resolve.ts";
118
+
109
119
  export interface DeciderDeps {
110
120
  config: OnlyneConfig;
111
121
  /** Registry id of this node's main session; children are bound under it. */
@@ -113,12 +123,22 @@ export interface DeciderDeps {
113
123
  contexts: ContextRegistry;
114
124
  log?(message: string, fields: Record<string, unknown>): void;
115
125
  listThreads(): Promise<Thread[]>;
126
+ /** The threads this node was invited into; they live on their owner, never here. */
127
+ listJoinedThreads(): Promise<JoinedThread[]>;
116
128
  listMessages(thread: ThreadRef, cursor?: string): Promise<Page<Entry>>;
129
+ /** The peers one thread already lists as members; a send reuses the thread a peer belongs to. */
130
+ threadMembers(thread: ThreadRef): Promise<NodeId[]>;
117
131
  /** Peers from the library's live discovery records, never a static configuration. */
118
132
  listPeers(): Promise<PeerRecord[]>;
133
+ /** Every trust row this node holds, in any phase. Identity outlives a discovery record. */
134
+ listTrust(): Promise<Array<{ node_id: NodeId; phase: string; base_url?: string }>>;
135
+ /** The inbound records this node holds; O-2A's candidate set reads the entries from them. */
136
+ listInbox(): Promise<Array<{ context?: string; attention: { entry?: Entry; disposition?: string } }>>;
119
137
  /** Contexts the target node publishes for `execution: existing`. */
120
138
  listReceiverContexts(nodeId: NodeId): Promise<ReceiverContext[]>;
121
139
  resourceQuery?(meta: Record<string, Json>): Promise<ResourceReadQuery>;
140
+ /** Test seam: answers the judge's questions without a model. Production never sets it. */
141
+ judge?: (state: Row, questions: Questions) => Promise<Answers>;
122
142
  }
123
143
 
124
144
  const TODO_ENTRY = "mouriya.omp.todo-more";
@@ -139,6 +159,8 @@ const INBOUND_CONTEXT_CAP = 3;
139
159
  /** Threads and entries a target question scans for recent unanswered inbound messages. */
140
160
  const RECENT_INBOUND_THREADS = 3;
141
161
  const RECENT_INBOUND_PER_THREAD = 3;
162
+ /** The O-2A candidate list is capped; the judge reads one line per candidate. */
163
+ const REPLY_CANDIDATES = 5;
142
164
  /** Newest branch rows that may reach the trigger derivation; older work is history, not decision material. */
143
165
  const HISTORY_ROWS = 40;
144
166
  /** One row's text cap: a pasted terminal dump must not crowd out the rest of the state. */
@@ -180,13 +202,21 @@ export function fitState(state: Row): { fitted: Row; dropped: string[]; chars: n
180
202
 
181
203
  /** Decisions go through OMP's `judge` role; the plugin never picks a target or a plan itself. */
182
204
  export class JudgeDecider {
183
- constructor(private readonly deps: DeciderDeps) {}
205
+ /** Deterministic reference resolution: one rule per reference, shared by every entry point. */
206
+ readonly refs: References;
207
+ private readonly guard: ActionGuard;
208
+
209
+ constructor(private readonly deps: DeciderDeps) {
210
+ this.refs = new References(this.deps);
211
+ this.guard = new ActionGuard({ contexts: deps.contexts, nodeId: deps.config.node.id });
212
+ }
184
213
 
185
214
  async decide(scope: DecisionScope): Promise<Action> {
186
215
  const explicit = await this.#explicit(scope);
187
216
  if (explicit !== undefined) {
188
- await this.#guard(scope, explicit);
189
- return explicit;
217
+ const filled = await continueThread(this.deps, explicit);
218
+ await this.#allow(scope, filled);
219
+ return filled;
190
220
  }
191
221
 
192
222
  // A subagent classifies exactly as the main session does and `#guard` decides what may run.
@@ -194,7 +224,13 @@ export class JudgeDecider {
194
224
  // asked for its own slice was told it had no rights at all (caerulea report 4, problem 1).
195
225
  // Classification costs one local judge call; a write is refused either way.
196
226
  const meta = scope.input.meta ?? {};
197
- const intent = await this.#intent(scope);
227
+ const settlement = settleIntent(await this.#intent(scope), meta);
228
+ if (settlement.override !== undefined) {
229
+ // The judge named an intent whose fields the call does not carry. Naming the fallback
230
+ // in the record is what makes a misread visible instead of a silent rewrite.
231
+ this.deps.log?.("onlyne.judge.overridden", { direction: "outbound", ...settlement.override });
232
+ }
233
+ const intent = settlement.intent;
198
234
  const action = intent === "communicate"
199
235
  ? await this.#communicate(scope, meta)
200
236
  : intent === "inspect"
@@ -203,14 +239,28 @@ export class JudgeDecider {
203
239
  ? await this.#threadAction(scope, meta)
204
240
  : intent === "manage_domain"
205
241
  ? await this.#domainAction(scope, meta)
206
- : await this.#resourceAction(meta);
207
- await this.#guard(scope, action);
208
- this.deps.log?.("onlyne.judge.action", { direction: "outbound", ...actionFacts(action) });
209
- return action;
242
+ : await resourceAction(meta);
243
+ const filled = await continueThread(this.deps, action);
244
+ await this.#allow(scope, filled);
245
+ this.deps.log?.("onlyne.judge.action", { direction: "outbound", ...actionFacts(filled) });
246
+ return filled;
247
+ }
248
+
249
+ /** The one guard every decided action passes before the client commits it. */
250
+ async #allow(scope: DecisionScope, action: Action): Promise<void> {
251
+ await this.guard.assert({ readOnly: scope.readOnly === true, agentId: scope.ctx.agent.id }, action);
210
252
  }
211
253
 
212
- /** Node O-1: one question, five options, no lists of peers, threads, or history. */
213
- async #intent(scope: DecisionScope): Promise<"communicate" | "inspect" | "manage_thread" | "manage_domain" | "operate_resource"> {
254
+ /**
255
+ * Node O-1: one question, only the options this call can execute, no lists of peers,
256
+ * threads, or history.
257
+ *
258
+ * `operate_resource` is offered only when the call names a complete resource action. The
259
+ * criterion that says a message mentioning a file is not this intent is still prose the
260
+ * model may misread; withholding the option makes the misread unanswerable rather than
261
+ * lethal (issue: outbound-intent-resource-false-positive).
262
+ */
263
+ async #intent(scope: DecisionScope): Promise<OutboundIntent> {
214
264
  const started = Date.now();
215
265
  const answers = await this.#judge(
216
266
  scope.ctx,
@@ -219,19 +269,7 @@ export class JudgeDecider {
219
269
  trigger: this.#trigger(scope.ctx),
220
270
  host_env: { session_type: scope.ctx.agent.kind, read_only: scope.readOnly === true },
221
271
  },
222
- {
223
- intent: {
224
- type: "choice",
225
- instructions: text("judge.o1.instructions"),
226
- criteria: {
227
- communicate: text("judge.o1.communicate"),
228
- inspect: text("judge.o1.inspect"),
229
- manage_thread: text("judge.o1.manage_thread"),
230
- manage_domain: text("judge.o1.manage_domain"),
231
- operate_resource: text("judge.o1.operate_resource"),
232
- },
233
- },
234
- },
272
+ intentQuestions({ resource: resourceFields(scope.input.meta) !== undefined }),
235
273
  scope.signal,
236
274
  );
237
275
  const intent = choice(answers, "intent");
@@ -244,8 +282,20 @@ export class JudgeDecider {
244
282
 
245
283
  /** Nodes O-2A / O-3A / O-3A-Ctx for a send, reply or followup. */
246
284
  async #communicate(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
247
- const reply = await this.#reply(meta);
285
+ // Every reference this send names — recipient, thread, mentions, the message it answers —
286
+ // is resolved here, once, before the delivery plan is chosen. A plan built on a
287
+ // half-resolved reference is how the same spelling came to mean two things on two paths
288
+ // (audit §2.7).
289
+ const refs = await this.refs.forSend(meta);
290
+ let reply = refs.reply;
248
291
  const explicitRelation = relation(meta);
292
+ if (reply === undefined && explicitRelation === undefined) {
293
+ // Node O-2A: no reference and no relation. The judge still answers one question —
294
+ // whether this call answers one of the inbound messages this context can see — and
295
+ // the plugin copies the exact ref from the record it names. Candidates are evidence
296
+ // for the question only: recency or being the sole pending message never binds one.
297
+ reply = await this.#originalQuestion(scope);
298
+ }
249
299
  const lockedRelation = reply !== undefined
250
300
  ? explicitRelation ?? await this.#relationQuestion(scope, meta)
251
301
  : undefined;
@@ -256,23 +306,21 @@ export class JudgeDecider {
256
306
  if (reply !== undefined) {
257
307
  // O-2A pre-pruning: the reply authority is the only target; meta.to and the judge's
258
308
  // answer must not move a reply to another node (caerulea report 3).
259
- const explicitTarget = await this.#target(meta);
260
- if (explicitTarget !== undefined && explicitTarget !== reply.authority) {
309
+ if (refs.target !== undefined && refs.target !== reply.authority) {
261
310
  throw new OnlyneError(
262
311
  "invalid_request",
263
- text("error.reply_authority", { authority: reply.authority, target: explicitTarget }),
312
+ text("error.reply_authority", { authority: reply.authority, target: refs.target }),
264
313
  );
265
314
  }
266
315
  to = reply.authority;
267
316
  } else {
268
- to = await this.#target(meta) ?? await this.#targetQuestion(scope, meta);
317
+ to = refs.target ?? await this.#targetQuestion(scope, meta);
269
318
  if (to.length === 0) {
270
319
  throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
271
320
  }
272
321
  }
273
322
 
274
323
  const plan = await this.#deliveryPlan(scope, meta, lockedRelation, reply !== undefined);
275
- const thread = await this.#sendThread(meta, to);
276
324
  const context = plan.execution === "new" ? undefined : await this.#receiverContext(scope, to, meta);
277
325
  if (plan.execution === "existing" && context === undefined) {
278
326
  // RFC 0005 §1: `existing` disambiguates or refuses. Quietly opening a new context made
@@ -282,36 +330,123 @@ export class JudgeDecider {
282
330
  text("error.execution_context_missing", { to }),
283
331
  );
284
332
  }
285
- const mentions = await this.#mentions(meta);
286
- return {
287
- kind: "send",
333
+ return sendAction({
288
334
  to,
289
- ...(thread === undefined ? {} : { thread }),
335
+ ...(refs.thread === undefined ? {} : { thread: refs.thread }),
290
336
  ...(context === undefined ? {} : { context }),
291
- ...(reply === undefined ? {} : { in_reply_to: reply.ref }),
337
+ ...(reply === undefined ? {} : { reply }),
292
338
  ...(lockedRelation === undefined ? {} : { relation: lockedRelation }),
293
- ...(mentions === undefined ? {} : { mentions }),
339
+ ...(refs.mentions === undefined ? {} : { mentions: refs.mentions }),
294
340
  response: plan.response,
295
341
  execution: plan.execution,
342
+ });
343
+ }
344
+
345
+ /**
346
+ * Node O-2A: the original this call answers. The judge chooses among the inbound records
347
+ * this context can see and nobody has answered yet, plus `new_message`; the plugin copies
348
+ * the exact MessageRef and the owner-attested reply authority from the record it names, so
349
+ * no message id ever travels from the model to the wire.
350
+ */
351
+ async #originalQuestion(scope: DecisionScope): Promise<ReplyReference | undefined> {
352
+ const candidates = await this.#replyCandidates(scope);
353
+ if (candidates.length === 0) return undefined;
354
+ const started = Date.now();
355
+ const keyOf = (index: number): string => `answer_${index + 1}`;
356
+ const facts = candidates.map((entry, index) => {
357
+ return {
358
+ key: keyOf(index),
359
+ from: entry.message.sender,
360
+ kind: entry.message.delivery_kind,
361
+ thread: threadKey(entry.thread_ref),
362
+ seq: entry.seq,
363
+ body: boundText(entry.message.msg),
364
+ };
365
+ });
366
+ const answers = await this.#judge(
367
+ scope.ctx,
368
+ { focus: this.#focus(scope), trigger: this.#trigger(scope.ctx), candidates: facts },
369
+ originalQuestions(facts),
370
+ scope.signal,
371
+ );
372
+ this.#record("outbound.o2a_original", scope.input, answers, Date.now() - started);
373
+ const picked = choice(answers, "original");
374
+ if (picked === undefined || picked === "new_message") return undefined;
375
+ const entry = candidates[Number(picked.slice("answer_".length)) - 1];
376
+ if (entry === undefined) {
377
+ throw new OnlyneError("decision_unavailable", text("error.reply_relation"));
378
+ }
379
+ return {
380
+ ref: { sender: entry.message.sender, id: entry.message.id },
381
+ authority: entry.reply_authority,
382
+ thread_ref: entry.thread_ref,
296
383
  };
297
384
  }
298
385
 
386
+ /**
387
+ * The inbound records this call can see and nobody has answered: rows delivered to this
388
+ * session's context (the main session also sees rows no context claimed), minus the host's
389
+ * handshake bookkeeping, minus entries a local reply already references. This set is what
390
+ * O-2A offers the judge; it is never an answer by itself (RFC 0016 §7).
391
+ */
392
+ async #replyCandidates(scope: DecisionScope): Promise<Entry[]> {
393
+ if (scope.readOnly === true) return [];
394
+ try {
395
+ const here = await this.#decisionContext(scope.ctx);
396
+ if (here === undefined) return [];
397
+ const rows = await this.deps.listInbox();
398
+ const candidates: Entry[] = [];
399
+ for (const row of rows) {
400
+ const entry = row.attention.entry;
401
+ if (entry === undefined || row.attention.disposition === "expired") continue;
402
+ if (entry.message.sender === this.deps.config.node.id) continue;
403
+ if (isTrustThread(this.deps.config.node.id, entry.thread_ref)) continue;
404
+ const deliveredHere = here.main
405
+ ? row.context === undefined || row.context === "main"
406
+ : row.context === here.alias;
407
+ if (!deliveredHere) continue;
408
+ if (await this.#answered(entry)) continue;
409
+ candidates.push(entry);
410
+ if (candidates.length >= REPLY_CANDIDATES) break;
411
+ }
412
+ return candidates;
413
+ } catch (error) {
414
+ // A failed read narrows the question, not the decision: the call proceeds as a new message.
415
+ this.deps.log?.("onlyne.judge.hint_unavailable", { what: "reply_candidates", error: message(error) });
416
+ return [];
417
+ }
418
+ }
419
+
420
+ /** The context this session answers in: the main session, or a registered subagent context. */
421
+ async #decisionContext(ctx: ExtensionContext): Promise<{ alias?: string; main: boolean } | undefined> {
422
+ if (ctx.agent.id === this.deps.mainAgentId) return { main: true };
423
+ const record = (await this.deps.contexts.list()).find(row => row.agent_id === ctx.agent.id);
424
+ return record === undefined ? undefined : { alias: record.alias, main: false };
425
+ }
426
+
427
+ /** Whether this node already answered the entry in the thread that holds it. */
428
+ async #answered(entry: Entry): Promise<boolean> {
429
+ let cursor: string | undefined;
430
+ for (let page = 0; page < MAX_TAIL_PAGES; page++) {
431
+ const result = await this.deps.listMessages(entry.thread_ref, cursor);
432
+ for (const row of result.items) {
433
+ const ref = row.message.meta?.in_reply_to;
434
+ if (row.message.sender === this.deps.config.node.id && ref !== undefined
435
+ && ref.sender === entry.message.sender && ref.id === entry.message.id) return true;
436
+ }
437
+ cursor = result.next_cursor ?? undefined;
438
+ if (cursor === undefined) return false;
439
+ }
440
+ return false;
441
+ }
442
+
299
443
  /** Node O-2A question 1: the reply/followup/new relation, asked only when meta left it open. */
300
444
  async #relationQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<"reply" | "followup"> {
301
445
  const started = Date.now();
302
446
  const answers = await this.#judge(
303
447
  scope.ctx,
304
448
  { focus: this.#focus(scope), in_reply_to: meta.in_reply_to ?? null },
305
- {
306
- relation: {
307
- type: "choice",
308
- instructions: text("judge.relation.instructions"),
309
- criteria: {
310
- reply: text("judge.relation.reply"),
311
- followup: text("judge.relation.followup"),
312
- },
313
- },
314
- },
449
+ relationQuestions(),
315
450
  scope.signal,
316
451
  );
317
452
  this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
@@ -324,40 +459,25 @@ export class JudgeDecider {
324
459
 
325
460
  /** Node O-2A question 2: the delivery target among live peers, asked only when meta.to is absent. */
326
461
  async #targetQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<NodeId> {
327
- const peers = await this.#peers();
328
- if (!peers.available) {
329
- throw new OnlyneError("unavailable", text("error.target_unresolved", { reason: peers.reason }));
330
- }
331
- // One discovered peer is the answer; none means the caller must name a target.
332
- if (peers.value.length === 0) throw new OnlyneError("not_found", text("error.no_receiver"));
333
- if (peers.value.length === 1) return peers.value[0]!.node_id;
462
+ // The same set `#peerId` resolves against: a peer this node holds a trust row with is a
463
+ // candidate even when discovery has nothing fresh to say about its address.
464
+ const peers = await knownPeers(this.deps);
465
+ // One known peer is the answer; none means the caller must name a target.
466
+ if (peers.length === 0) throw new OnlyneError("not_found", text("error.no_receiver"));
467
+ if (peers.length === 1) return peers[0]!.node_id;
334
468
  const started = Date.now();
335
469
  const answers = await this.#judge(
336
470
  scope.ctx,
337
471
  {
338
472
  focus: { ...this.#focus(scope), meta_to: meta.to ?? null },
339
- candidate_peers: peers.value.map(peer => ({
473
+ candidate_peers: peers.map(peer => ({
340
474
  node_id: peer.node_id,
341
475
  alias: lastSegment(peer.node_id),
342
476
  last_seen: peer.last_seen,
343
477
  })),
344
478
  recent_inbound_unanswered: await this.#unanswered(scope.ctx),
345
479
  },
346
- {
347
- target: {
348
- type: "choice",
349
- instructions: text("judge.target.instructions"),
350
- criteria: Object.fromEntries(peers.value.map(peer => [
351
- peer.node_id,
352
- text("judge.target.peer", {
353
- node: peer.node_id,
354
- base_url: peer.base_url,
355
- instance: peer.instance_id,
356
- last_seen: peer.last_seen,
357
- }),
358
- ])),
359
- },
360
- },
480
+ targetQuestions(peers),
361
481
  scope.signal,
362
482
  );
363
483
  this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
@@ -365,7 +485,7 @@ export class JudgeDecider {
365
485
  if (chosen === undefined || chosen === "none") {
366
486
  throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
367
487
  }
368
- if (!peers.value.some(peer => peer.node_id === chosen)) {
488
+ if (!peers.some(peer => peer.node_id === chosen)) {
369
489
  throw new OnlyneError("decision_unavailable", text("error.target_not_published", { chosen }));
370
490
  }
371
491
  return chosen;
@@ -378,63 +498,25 @@ export class JudgeDecider {
378
498
  relation: "reply" | "followup" | undefined,
379
499
  isReply: boolean,
380
500
  ): Promise<{ response: "await" | "async" | "none"; execution: "existing" | "new" }> {
381
- const response = this.#inherited(meta, "response");
382
- const execution = this.#inherited(meta, "execution");
383
- if (response !== undefined && execution !== undefined) {
384
- return { response: response as "await" | "async" | "none", execution: execution as "existing" | "new" };
501
+ const inherited = inheritedModes(meta);
502
+ // A plan the caller stated completely is not a question: asking it would offer to
503
+ // overrule the one party that knows what the call wants.
504
+ if (inherited.response !== undefined && inherited.execution !== undefined) {
505
+ return resolvedModes(inherited, {});
385
506
  }
386
507
  const started = Date.now();
387
508
  const answers = await this.#judge(
388
509
  scope.ctx,
389
510
  { focus: this.#focus(scope), relation: relation ?? "new" },
390
- {
391
- ...(response === undefined
392
- ? {
393
- response: {
394
- type: "choice",
395
- instructions: text("judge.response.instructions"),
396
- criteria: {
397
- none: text("judge.response.none"),
398
- async: text("judge.response.async"),
399
- await: text("judge.response.await"),
400
- },
401
- },
402
- }
403
- : {}),
404
- ...(execution === undefined
405
- ? {
406
- execution: {
407
- type: "choice",
408
- instructions: text("judge.execution.instructions"),
409
- criteria: {
410
- existing: isReply
411
- ? text("judge.execution.existing.reply")
412
- : text("judge.execution.existing.new"),
413
- new: text("judge.execution.new"),
414
- },
415
- },
416
- }
417
- : {}),
418
- },
511
+ planQuestions({
512
+ response: inherited.response === undefined,
513
+ execution: inherited.execution === undefined,
514
+ reply: isReply,
515
+ }),
419
516
  scope.signal,
420
517
  );
421
518
  this.#record("outbound.o3a", scope.input, answers, Date.now() - started);
422
- const chosenResponse = response ?? choice(answers, "response");
423
- const chosenExecution = execution ?? choice(answers, "execution");
424
- if (chosenResponse !== "none" && chosenResponse !== "async" && chosenResponse !== "await") {
425
- throw new OnlyneError("decision_unavailable", text("error.response_mode"));
426
- }
427
- if (chosenExecution !== "existing" && chosenExecution !== "new") {
428
- throw new OnlyneError("decision_unavailable", text("error.execution_mode"));
429
- }
430
- return { response: chosenResponse, execution: chosenExecution };
431
- }
432
-
433
- #inherited(meta: Record<string, Json>, key: string): "await" | "async" | "none" | "existing" | "new" | undefined {
434
- const value = meta[key];
435
- if (value === "await" || value === "async" || value === "none" || value === "existing" || value === "new") return value;
436
- if (value !== undefined) throw new OnlyneError("invalid_request", text("error.meta_mode", { key }));
437
- return undefined;
519
+ return resolvedModes(inherited, answers);
438
520
  }
439
521
 
440
522
  /**
@@ -465,19 +547,7 @@ export class JudgeDecider {
465
547
  const answers = await this.#judge(
466
548
  scope.ctx,
467
549
  { focus: this.#focus(scope), target_published_contexts: published },
468
- {
469
- context: {
470
- type: "choice",
471
- instructions: text("judge.context.instructions"),
472
- criteria: {
473
- ...Object.fromEntries(published.map(context => [
474
- context.alias,
475
- text("judge.context.published", { summary: context.summary, state: context.state }),
476
- ])),
477
- none: text("judge.context.none"),
478
- },
479
- },
480
- },
550
+ contextQuestions(published),
481
551
  scope.signal,
482
552
  );
483
553
  this.#record("outbound.o3a-ctx", scope.input, answers, Date.now() - started);
@@ -488,31 +558,6 @@ export class JudgeDecider {
488
558
  return alias;
489
559
  }
490
560
 
491
- /**
492
- * The thread a send names. The thread decides where the message is stored; `to` decides who
493
- * is addressed. An initiator owns its thread for the whole conversation, so every member
494
- * writes into it and the client routes each message to that owner. A bare id that names no
495
- * local thread is the target's thread id, as a receipt reports it.
496
- */
497
- async #sendThread(meta: Record<string, Json> | undefined, to: NodeId): Promise<ThreadRef | undefined> {
498
- if (meta?.thread === undefined) return undefined;
499
- const value = meta.thread;
500
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
501
- return this.#thread(meta);
502
- }
503
- if (typeof value !== "string" || value.length === 0) {
504
- throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
505
- }
506
- const matches = (await this.deps.listThreads()).filter(thread => thread.thread_ref.id === value || thread.title === value);
507
- if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_local_thread", { value }));
508
- if (matches.length === 1) return matches[0]!.thread_ref;
509
- const slash = value.indexOf("/");
510
- if (slash > 0 && slash < value.length - 1) {
511
- return { owner: value.slice(0, slash), id: value.slice(slash + 1) };
512
- }
513
- return { owner: to, id: value };
514
- }
515
-
516
561
  /**
517
562
  * One inbound entry's route. `plan` is the sender's delivery plan when the delivery named
518
563
  * one: an explicit `plan.context` is binding, `execution: new` is only a preference.
@@ -640,16 +685,7 @@ export class JudgeDecider {
640
685
  current_work: this.#trigger(scope),
641
686
  thread_dialogue: await this.#threadEvidenceFor([entry.thread_ref]),
642
687
  },
643
- {
644
- needs_answer: {
645
- type: "choice",
646
- instructions: text("judge.needs_answer.instructions"),
647
- criteria: {
648
- yes: text("judge.needs_answer.yes"),
649
- no: text("judge.needs_answer.no"),
650
- },
651
- },
652
- },
688
+ needsAnswerQuestions(),
653
689
  undefined,
654
690
  );
655
691
  this.#record("inbound.i0", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
@@ -690,11 +726,6 @@ export class JudgeDecider {
690
726
  const started = Date.now();
691
727
  // `fragment` never starts a turn, so a message that needs an answer can never be one;
692
728
  // offering it would be an option whose choice guarantees the obligation is missed.
693
- const criteria: Record<string, string> = {
694
- steer: text("judge.insertion.steer"),
695
- follow_up: text("judge.insertion.follow_up"),
696
- ...(needsAnswer ? {} : { fragment: text("judge.insertion.fragment") }),
697
- };
698
729
  const answers = await this.#judge(
699
730
  scope,
700
731
  {
@@ -702,13 +733,7 @@ export class JudgeDecider {
702
733
  host_running_state: { is_turn_active: !scope.isIdle(), plan_mode: this.#planActive(scope) },
703
734
  urgency_signals: { has_stop_or_change: URGENCY_CUES.some(cue => body.includes(cue)) },
704
735
  },
705
- {
706
- insertion_mode: {
707
- type: "choice",
708
- instructions: text("judge.insertion.instructions"),
709
- criteria,
710
- },
711
- },
736
+ insertionQuestions({ fragment: !needsAnswer }),
712
737
  );
713
738
  this.#record("inbound.i2", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
714
739
  const mode = choice(answers, "insertion_mode");
@@ -833,6 +858,7 @@ export class JudgeDecider {
833
858
  throw new OnlyneError("decision_unavailable", text("error.state_budget", { chars, cap: STATE_BUDGET_CHARS }));
834
859
  }
835
860
  if (dropped.length > 0) this.deps.log?.("onlyne.judge.trimmed", { dropped, chars });
861
+ if (this.deps.judge !== undefined) return await this.deps.judge(fitted as Row, questions);
836
862
  const settings = (await Settings.current) ?? (await Settings.loadReadOnly({ cwd: scope.cwd }));
837
863
  const judge = resolveJudge({
838
864
  settings,
@@ -862,8 +888,9 @@ export class JudgeDecider {
862
888
  }
863
889
 
864
890
  /**
865
- * Only stable, explicit fields route without a decision. Everything semantic,
866
- * including a bare thread reference, goes to the judge.
891
+ * Only stable, explicit fields route without a decision. A reference is resolved, never
892
+ * judged: `meta.thread`, `meta.to` and `meta.resource` name a record, and whether the
893
+ * message means to use them at all is what the judge answers.
867
894
  */
868
895
  async #explicit(scope: DecisionScope): Promise<Action | undefined> {
869
896
  const meta = scope.input.meta;
@@ -871,8 +898,8 @@ export class JudgeDecider {
871
898
  if (meta.scope === "resources") return this.#read(scope, "resources");
872
899
  const op = stringField(meta, "op");
873
900
  if (op !== undefined && isThreadOp(op)) {
874
- const thread = await this.#thread(meta);
875
- const to = await this.#target(meta);
901
+ const thread = await this.refs.thread(meta);
902
+ const to = await this.refs.target(meta);
876
903
  const title = stringField(meta, "title");
877
904
  if (op === "create" && title === undefined) throw new OnlyneError("invalid_request", text("error.thread_title_required"));
878
905
  if (op !== "create" && op !== "list" && thread === undefined) {
@@ -890,22 +917,7 @@ export class JudgeDecider {
890
917
  };
891
918
  }
892
919
 
893
- const resource = recordField(meta, "resource");
894
- if (resource !== undefined) {
895
- const owner = stringField(resource, "owner");
896
- const id = stringField(resource, "id");
897
- const operation = stringField(meta, "operation");
898
- if (owner === undefined || id === undefined || operation === undefined) {
899
- throw new OnlyneError("invalid_request", text("error.resource_fields"));
900
- }
901
- return {
902
- kind: "resource",
903
- resource: { owner, id },
904
- operation,
905
- ...(meta.data === undefined ? {} : { data: meta.data }),
906
- ...(typeof meta.expected_revision === "string" ? { expected_revision: meta.expected_revision } : {}),
907
- };
908
- }
920
+ if (recordField(meta, "resource") !== undefined) return resourceAction(meta);
909
921
 
910
922
  const scopeName = stringField(meta, "scope");
911
923
  if (scopeName !== undefined) {
@@ -925,7 +937,7 @@ export class JudgeDecider {
925
937
  if (this.deps.resourceQuery === undefined) throw new OnlyneError("unavailable", text("error.resource_host_unavailable"));
926
938
  return { kind: "read", scope: name, query: await this.deps.resourceQuery(meta) };
927
939
  }
928
- const thread = await this.#thread(meta);
940
+ const thread = await this.refs.thread(meta);
929
941
  const page_size = pageSize(meta);
930
942
  const cursor = stringField(meta, "cursor");
931
943
  const seq = stringField(meta, "seq");
@@ -955,7 +967,7 @@ export class JudgeDecider {
955
967
  attention: attention(meta),
956
968
  };
957
969
  }
958
- const resolved = await this.#messageRef(meta, thread);
970
+ const resolved = await this.refs.message(meta, thread);
959
971
  if (resolved === undefined) {
960
972
  throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
961
973
  }
@@ -976,20 +988,7 @@ export class JudgeDecider {
976
988
  const answers = await this.#judge(
977
989
  scope.ctx,
978
990
  { focus: { delivery_kind: scope.input.delivery_kind, msg: scope.input.msg, meta: meta ?? null } },
979
- {
980
- read_scope: {
981
- type: "choice",
982
- instructions: text("judge.read_scope.instructions"),
983
- criteria: {
984
- peers: text("judge.read_scope.peers"),
985
- inbox: text("judge.read_scope.inbox"),
986
- messages: text("judge.read_scope.messages"),
987
- evidence: text("judge.read_scope.evidence"),
988
- resources: text("judge.read_scope.resources"),
989
- none: text("judge.read_scope.none"),
990
- },
991
- },
992
- },
991
+ readScopeQuestions(),
993
992
  scope.signal,
994
993
  );
995
994
  this.#record("outbound.o2b", scope.input, answers, Date.now() - started);
@@ -1005,7 +1004,7 @@ export class JudgeDecider {
1005
1004
  throw new OnlyneError("decision_unavailable", text("error.read_scope_unknown", { scope: scopeName }));
1006
1005
  }
1007
1006
  const name: ActionReadScope = scopeName === "peers" ? "neighbors" : scopeName;
1008
- const thread = await this.#thread(meta);
1007
+ const thread = await this.refs.thread(meta);
1009
1008
  if (name === "messages") {
1010
1009
  const chosen = thread ?? await this.#threadQuestion(scope, "outbound.o3b-th");
1011
1010
  return {
@@ -1019,7 +1018,7 @@ export class JudgeDecider {
1019
1018
  };
1020
1019
  }
1021
1020
  if (name === "evidence") {
1022
- const resolved = await this.#messageRef(meta, thread);
1021
+ const resolved = await this.refs.message(meta, thread);
1023
1022
  if (resolved === undefined) {
1024
1023
  throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
1025
1024
  }
@@ -1054,21 +1053,7 @@ export class JudgeDecider {
1054
1053
  next_seq: thread.next_seq,
1055
1054
  })),
1056
1055
  },
1057
- {
1058
- thread: {
1059
- type: "choice",
1060
- instructions: text("judge.thread.instructions"),
1061
- criteria: Object.fromEntries(threads.map(thread => [
1062
- thread.thread_ref.id,
1063
- text("judge.thread.item", {
1064
- title: thread.title,
1065
- owner: thread.thread_ref.owner,
1066
- id: thread.thread_ref.id,
1067
- state: thread.state,
1068
- }),
1069
- ])),
1070
- },
1071
- },
1056
+ threadQuestions(threads.map(thread => ({ ref: thread.thread_ref, title: thread.title, state: thread.state }))),
1072
1057
  scope.signal,
1073
1058
  );
1074
1059
  this.#record(node, scope.input, answers, Date.now() - started);
@@ -1076,7 +1061,7 @@ export class JudgeDecider {
1076
1061
  if (alias === undefined || alias === "none") {
1077
1062
  throw new OnlyneError("decision_unavailable", text("error.local_thread_choice"));
1078
1063
  }
1079
- return this.#threadByAlias(alias);
1064
+ return this.refs.threadByAlias(alias);
1080
1065
  }
1081
1066
 
1082
1067
  /** Node O-2C: the local thread operation. */
@@ -1085,20 +1070,7 @@ export class JudgeDecider {
1085
1070
  const answers = await this.#judge(
1086
1071
  scope.ctx,
1087
1072
  { focus: this.#focus(scope) },
1088
- {
1089
- thread_op: {
1090
- type: "choice",
1091
- instructions: text("judge.thread_op.instructions"),
1092
- criteria: {
1093
- create: text("judge.thread_op.create"),
1094
- close: text("judge.thread_op.close"),
1095
- list: text("judge.thread_op.list"),
1096
- describe: text("judge.thread_op.describe"),
1097
- member_add: text("judge.thread_op.member_add"),
1098
- member_remove: text("judge.thread_op.member_remove"),
1099
- },
1100
- },
1101
- },
1073
+ threadOpQuestions(),
1102
1074
  scope.signal,
1103
1075
  );
1104
1076
  this.#record("outbound.o2c", scope.input, answers, Date.now() - started);
@@ -1109,9 +1081,9 @@ export class JudgeDecider {
1109
1081
  // A dependent second call: which thread the operation names is only decidable once the
1110
1082
  // operation is known, and meta.thread settles it without a call (design §4, stepwise).
1111
1083
  const thread = op === "create" || op === "list"
1112
- ? await this.#thread(meta)
1113
- : await this.#thread(meta) ?? await this.#threadQuestion(scope, "outbound.o2c-thread");
1114
- const to = await this.#target(meta);
1084
+ ? await this.refs.thread(meta)
1085
+ : await this.refs.thread(meta) ?? await this.#threadQuestion(scope, "outbound.o2c-thread");
1086
+ const to = await this.refs.target(meta);
1115
1087
  const title = stringField(meta, "title");
1116
1088
  if ((op === "member_add" || op === "member_remove") && to === undefined) {
1117
1089
  throw new OnlyneError("decision_unavailable", text("error.thread_op_target", { op }));
@@ -1134,20 +1106,7 @@ export class JudgeDecider {
1134
1106
  const answers = await this.#judge(
1135
1107
  scope.ctx,
1136
1108
  { focus: this.#focus(scope) },
1137
- {
1138
- domain_op: {
1139
- type: "choice",
1140
- instructions: text("judge.domain_op.instructions"),
1141
- criteria: {
1142
- create: text("judge.domain_op.create"),
1143
- join: text("judge.domain_op.join"),
1144
- leave: text("judge.domain_op.leave"),
1145
- list: text("judge.domain_op.list"),
1146
- describe: text("judge.domain_op.describe"),
1147
- discover: text("judge.domain_op.discover"),
1148
- },
1149
- },
1150
- },
1109
+ domainOpQuestions(),
1151
1110
  scope.signal,
1152
1111
  );
1153
1112
  this.#record("outbound.o2d", scope.input, answers, Date.now() - started);
@@ -1167,24 +1126,6 @@ export class JudgeDecider {
1167
1126
  };
1168
1127
  }
1169
1128
 
1170
- /** The resource branch carries no judge node: meta names every field. */
1171
- async #resourceAction(meta: Record<string, Json>): Promise<Action> {
1172
- const resource = recordField(meta, "resource");
1173
- const owner = resource === undefined ? undefined : stringField(resource, "owner");
1174
- const id = resource === undefined ? undefined : stringField(resource, "id");
1175
- const operation = stringField(meta, "operation");
1176
- if (owner === undefined || id === undefined || operation === undefined) {
1177
- throw new OnlyneError("invalid_request", text("error.resource_action_fields"));
1178
- }
1179
- return {
1180
- kind: "resource",
1181
- resource: { owner, id },
1182
- operation,
1183
- ...(meta.data === undefined ? {} : { data: meta.data }),
1184
- ...(typeof meta.expected_revision === "string" ? { expected_revision: meta.expected_revision } : {}),
1185
- };
1186
- }
1187
-
1188
1129
  /** The golden-triage head of every node state: what is being asked, verbatim. */
1189
1130
  #focus(scope: DecisionScope): Row {
1190
1131
  return {
@@ -1234,224 +1175,12 @@ export class JudgeDecider {
1234
1175
  }
1235
1176
 
1236
1177
 
1237
- async #thread(meta: Record<string, Json> | undefined): Promise<ThreadRef | undefined> {
1238
- if (meta === undefined) return undefined;
1239
- const value = meta.thread;
1240
- if (value === undefined) return undefined;
1241
- if (typeof value === "string") return this.#threadByAlias(value);
1242
- const record = recordField(meta, "thread");
1243
- const owner = record === undefined ? undefined : stringField(record, "owner");
1244
- const id = record === undefined ? undefined : stringField(record, "id");
1245
- if (owner !== undefined && id !== undefined) return { owner, id };
1246
- throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
1247
- }
1248
-
1249
- async #threadByAlias(alias: string): Promise<ThreadRef> {
1250
- const threads = await this.deps.listThreads();
1251
- const matches = threads.filter(thread => thread.thread_ref.id === alias || thread.title === alias);
1252
- if (matches.length === 1) return matches[0]!.thread_ref;
1253
- if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_thread", { alias }));
1254
- if (alias.includes("/")) {
1255
- const [owner, id] = alias.split("/", 2);
1256
- if (owner !== undefined && id !== undefined && owner.length > 0 && id.length > 0) return { owner, id };
1257
- }
1258
- throw new OnlyneError("not_found", text("error.thread_not_found", { alias }));
1259
- }
1260
-
1261
- async #target(meta: Record<string, Json> | undefined): Promise<NodeId | undefined> {
1262
- if (meta === undefined) return undefined;
1263
- const value = meta.to;
1264
- if (value === undefined) return undefined;
1265
- if (typeof value !== "string") {
1266
- throw new OnlyneError("invalid_request", text("error.meta_to_single"));
1267
- }
1268
- if (value.length === 0) throw new OnlyneError("invalid_request", text("error.meta_to_empty"));
1269
- return this.#peerId(value);
1270
- }
1271
-
1272
- /**
1273
- * Resolve an explicit target against live discovery. A bare alias is accepted
1274
- * only when exactly one known node's last URN segment names it.
1275
- */
1276
- async #peerId(alias: string): Promise<NodeId> {
1277
- const peers = await this.#peers();
1278
- if (!peers.available) {
1279
- throw new OnlyneError("unavailable", text("error.target_unresolved", { reason: peers.reason }));
1280
- }
1281
- const ids = [this.deps.config.node.id, ...peers.value.map(peer => peer.node_id)];
1282
- if (alias === "self") return this.deps.config.node.id;
1283
- if (ids.includes(alias)) return alias;
1284
- const matches = ids.filter(id => lastSegment(id) === alias);
1285
- if (matches.length === 1) return matches[0]!;
1286
- if (matches.length > 1) {
1287
- throw new OnlyneError("ambiguous_target", text("error.alias_ambiguous", { alias, matches: matches.join(", ") }));
1288
- }
1289
- throw new OnlyneError("not_found", text("error.alias_not_found", { alias }));
1290
- }
1291
-
1292
- async #mentions(meta: Record<string, Json> | undefined): Promise<string[] | undefined> {
1293
- if (meta === undefined) return undefined;
1294
- const value = meta.mentions;
1295
- if (value === undefined) return undefined;
1296
- if (!Array.isArray(value)) throw new OnlyneError("invalid_request", text("error.mentions_array"));
1297
- const resolved: string[] = [];
1298
- for (const item of value) {
1299
- if (typeof item !== "string" || item.length === 0) {
1300
- throw new OnlyneError("invalid_request", text("error.mention_empty"));
1301
- }
1302
- if (item === "@all") {
1303
- resolved.push(item);
1304
- continue;
1305
- }
1306
- resolved.push(await this.#peerId(item.startsWith("@") ? item.slice(1) : item));
1307
- }
1308
- return resolved;
1309
- }
1310
-
1311
- async #reply(meta: Record<string, Json> | undefined): Promise<{ ref: MessageRef; authority: NodeId; thread_ref?: ThreadRef } | undefined> {
1312
- if (meta === undefined) return undefined;
1313
- const value = meta.in_reply_to;
1314
- if (value === undefined) return undefined;
1315
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1316
- throw new OnlyneError("invalid_request", text("error.in_reply_to_object"));
1317
- }
1318
- const record = value as Record<string, Json>;
1319
- const sender = stringField(record, "sender");
1320
- const id = stringField(record, "id");
1321
- if (sender !== undefined && id !== undefined) {
1322
- // The authority is an identity, not the caller's spelling: `caerulea` and
1323
- // `urn:onlyne:agent:caerulea` name the same node, and the delivery target is always
1324
- // resolved. Comparing a raw string against a resolved id produced two contradictory
1325
- // errors in a row (cyanea report 1, problem 1).
1326
- const authority = await this.#peerId(sender).catch(() => sender);
1327
- return { ref: { sender, id }, authority };
1328
- }
1329
- const threadAlias = stringField(record, "thread");
1330
- const seq = seqField(record, "seq");
1331
- if (threadAlias === undefined) {
1332
- throw new OnlyneError("invalid_request", text("error.in_reply_to_shape"));
1333
- }
1334
- if (seq === undefined) {
1335
- throw new OnlyneError("invalid_request", text("error.in_reply_to_seq"));
1336
- }
1337
- const thread = await this.#threadByAlias(threadAlias);
1338
- const entry = await this.#entryAt(thread, seq);
1339
- if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: threadAlias, seq }));
1340
- return {
1341
- ref: { sender: entry.message.sender, id: entry.message.id },
1342
- authority: entry.reply_authority,
1343
- thread_ref: entry.thread_ref,
1344
- };
1345
- }
1346
-
1347
- /**
1348
- * The message a read names, with the thread it was resolved in. `{sender,id}` carries no
1349
- * thread here; `{thread,seq}` does, and an explicit meta.thread that contradicts the
1350
- * reference is refused rather than silently winning.
1351
- */
1352
- async #messageRef(meta: Record<string, Json>, thread?: ThreadRef): Promise<{ ref: MessageRef; thread?: ThreadRef } | undefined> {
1353
- const value = meta.message;
1354
- const record = value !== null && typeof value === "object" && !Array.isArray(value)
1355
- ? (value as Record<string, Json>)
1356
- : undefined;
1357
- const sender = record === undefined ? undefined : stringField(record, "sender");
1358
- const id = record === undefined ? undefined : stringField(record, "id");
1359
- if (sender !== undefined && id !== undefined) {
1360
- return { ref: { sender, id }, ...(thread === undefined ? {} : { thread }) };
1361
- }
1362
- // meta.message takes the same two shapes as meta.in_reply_to, including its own thread
1363
- // reference: `{thread, seq}` is the form a reader copies out of another reference, and
1364
- // refusing it while accepting `{sender, id}` made the same fact two spellings (caerulea 4).
1365
- const alias = record === undefined ? undefined : stringField(record, "thread");
1366
- const embedded = alias === undefined ? undefined : await this.#threadByAlias(alias);
1367
- if (thread !== undefined && embedded !== undefined && threadKey(thread) !== threadKey(embedded)) {
1368
- throw new OnlyneError(
1369
- "invalid_request",
1370
- text("error.message_thread_conflict", { thread: threadKey(thread), embedded: threadKey(embedded) }),
1371
- );
1372
- }
1373
- const scope = thread ?? embedded;
1374
- if (scope === undefined) return undefined;
1375
- const seq = (record === undefined ? undefined : seqField(record, "seq")) ?? seqField(meta, "seq");
1376
- if (seq === undefined) return undefined;
1377
- const entry = await this.#entryAt(scope, seq);
1378
- if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: scope.id, seq }));
1379
- return { ref: { sender: entry.message.sender, id: entry.message.id }, thread: scope };
1380
- }
1381
-
1382
- async #entryAt(thread: ThreadRef, seq: string): Promise<Entry | undefined> {
1383
- let cursor: string | undefined;
1384
- for (let page = 0; page < MAX_TAIL_PAGES; page++) {
1385
- const result: Page<Entry> = await this.deps.listMessages(thread, cursor);
1386
- const found = result.items.find(entry => entry.seq === seq);
1387
- if (found !== undefined) return found;
1388
- cursor = result.next_cursor ?? undefined;
1389
- if (cursor === undefined) return undefined;
1390
- }
1391
- return undefined;
1392
- }
1393
-
1394
1178
  /**
1395
- * A subagent may read only the threads its own context records authorize on
1396
- * this node. The unfiltered node-wide views stay with the main session.
1179
+ * RFC 0018 §3.1: the node a mechanical `op` names, resolved against the same peer set a send
1180
+ * resolves `meta.to` from, so a name that addresses a peer once addresses it everywhere.
1397
1181
  */
1398
- async #guard(scope: DecisionScope, action: Action): Promise<void> {
1399
- if (scope.readOnly !== true) return;
1400
- if (action.kind !== "read") {
1401
- throw new OnlyneError(
1402
- "forbidden",
1403
- text("error.guard_write"),
1404
- );
1405
- }
1406
- if (action.scope === "resources") throw new OnlyneError("forbidden", text("error.resources_main_only"));
1407
- if (action.scope === "directory" || action.scope === "neighbors" || action.scope === "threads" || action.scope === "inbox") {
1408
- throw new OnlyneError(
1409
- "forbidden",
1410
- text("error.guard_node_listing"),
1411
- );
1412
- }
1413
- const records = await this.#authorizedRecords(scope.ctx.agent.id);
1414
- if (action.scope === "evidence") {
1415
- const ref = action.message;
1416
- const tied = ref !== undefined && records.some(record =>
1417
- record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
1418
- );
1419
- if (!tied) {
1420
- throw new OnlyneError("forbidden", text("error.guard_evidence"));
1421
- }
1422
- return;
1423
- }
1424
- // `{sender,id}` names no thread, so the record that received that message is the
1425
- // authorization — the same tie evidence uses.
1426
- if (action.scope === "message" && action.thread === undefined && action.message !== undefined) {
1427
- const ref = action.message;
1428
- const tied = records.some(record =>
1429
- record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
1430
- );
1431
- if (!tied) {
1432
- throw new OnlyneError("forbidden", text("error.guard_message"));
1433
- }
1434
- return;
1435
- }
1436
- if (action.thread === undefined) {
1437
- throw new OnlyneError("forbidden", text("error.guard_thread_required"));
1438
- }
1439
- const key = threadKey(action.thread);
1440
- if (!records.some(record => record.thread_ref !== undefined && threadKey(record.thread_ref) === key)) {
1441
- throw new OnlyneError("forbidden", text("error.guard_no_slice", { thread: key }));
1442
- }
1443
- }
1444
-
1445
- async #authorizedRecords(agentId: string): Promise<ContextRecord[]> {
1446
- let records: ContextRecord[];
1447
- try {
1448
- records = await this.deps.contexts.list();
1449
- } catch (error) {
1450
- throw new OnlyneError("unavailable", text("error.registry_unreadable", { error: message(error) }));
1451
- }
1452
- return records.filter(record =>
1453
- record.node_id === this.deps.config.node.id && record.agent_id === agentId && record.thread_ref !== undefined,
1454
- );
1182
+ async peerId(alias: string): Promise<NodeId> {
1183
+ return this.refs.peerId(alias);
1455
1184
  }
1456
1185
 
1457
1186
  async #contextRows(): Promise<Source<Row[]>> {
@@ -1484,14 +1213,6 @@ export class JudgeDecider {
1484
1213
  }
1485
1214
 
1486
1215
 
1487
- async #peers(): Promise<Source<PeerRecord[]>> {
1488
- try {
1489
- return ok(await this.deps.listPeers());
1490
- } catch (error) {
1491
- return none(text("reason.discovery_unreadable", { error: message(error) }));
1492
- }
1493
- }
1494
-
1495
1216
  async #receiverContexts(nodeId: NodeId): Promise<Source<ReceiverContext[]>> {
1496
1217
  try {
1497
1218
  return ok(await this.deps.listReceiverContexts(nodeId));
@@ -1624,12 +1345,6 @@ function actionFacts(action: Action): Record<string, string> {
1624
1345
  return facts;
1625
1346
  }
1626
1347
 
1627
- function choice(answers: Answers, key: string): string | undefined {
1628
- const answer = answers[key];
1629
- if (answer === undefined || answer === null || typeof answer !== "object") return undefined;
1630
- return typeof answer.choice === "string" ? answer.choice : undefined;
1631
- }
1632
-
1633
1348
  function stringField(record: Record<string, Json>, key: string): string | undefined {
1634
1349
  const value = record[key];
1635
1350
  return typeof value === "string" && value.length > 0 ? value : undefined;
@@ -1663,7 +1378,8 @@ function pageSize(meta: Record<string, Json>): number | undefined {
1663
1378
 
1664
1379
  function attention(meta: Record<string, Json>): "inbox" | "thread" {
1665
1380
  const value = meta.attention;
1666
- if (value === undefined) return "thread";
1381
+ // RFC 0005 §1: reads default to the attention list; `thread` is the switch that widens one.
1382
+ if (value === undefined) return "inbox";
1667
1383
  if (value === "inbox" || value === "thread") return value;
1668
1384
  throw new OnlyneError("invalid_request", text("error.attention"));
1669
1385
  }
@@ -1684,10 +1400,6 @@ function branchOf(ctx: ExtensionContext): readonly SessionEntry[] {
1684
1400
  return ctx.sessionManager.getBranch();
1685
1401
  }
1686
1402
 
1687
- function threadKey(ref: ThreadRef): string {
1688
- return `${ref.owner}/${ref.id}`;
1689
- }
1690
-
1691
1403
  /**
1692
1404
  * A wording hint about whether the sender expects an answer: metadata says reply, or the body
1693
1405
  * greets or asks. It is evidence for node I-0, never the decision — a completed reply also
@@ -1700,13 +1412,6 @@ export function needsReplyOf(entry: Entry): boolean {
1700
1412
  return REPLY_CUES.some(cue => body.includes(cue.toLowerCase())) || /\?|?|\bplease\b|期待/.test(entry.message.msg);
1701
1413
  }
1702
1414
 
1703
- /** A node id's last URN segment; plain ids are their own segment. */
1704
- function lastSegment(nodeId: NodeId): string {
1705
- const trimmed = nodeId.replace(/\/+$/, "");
1706
- const parts = trimmed.split(/[:/]/).filter(part => part.length > 0);
1707
- return parts.at(-1) ?? trimmed;
1708
- }
1709
-
1710
1415
  function entryFacts(entry: Entry): Row {
1711
1416
  return {
1712
1417
  thread_ref: entry.thread_ref,