@milpa/comal 0.2.0 → 0.2.2

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.
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto';
2
2
  import { digest } from '../../operations.mjs';
3
3
  import { clean, excerpt } from './bound.mjs';
4
4
  import { messageOrNull, messageOfDelivery, taskForMessage, pendingDelivery, planOrNull } from './lookup.mjs';
5
+ import { METHOD_CAPS } from './client.mjs';
6
+ import { decisionBlock, chosenOption, adrScope, draftAdr, adrDigest } from './decision.mjs';
5
7
  import { t, registerMessages } from '../../i18n/index.mjs';
6
8
 
7
9
  // Fase 2: cuatro acciones sobre el enjambre. Cada una lleva una tarjeta de
@@ -30,7 +32,38 @@ registerMessages({
30
32
  'Confirm delivery (msg.ack): {v0}': 'Confirmar la entrega (msg.ack): {v0}',
31
33
  'yes, the message is marked read': 'sí, el mensaje queda leído',
32
34
  'no': 'no',
33
- 'Decision note (only in the Comal receipt; phase 3 will store it as an ADR):': 'Nota de decisión (sólo en el recibo de Comal; la fase 3 la guardará como ADR):',
35
+ 'Decision note (only in the Comal receipt; Decide records it as an ADR in relayd):': 'Nota de decisión (sólo en el recibo de Comal; Decide la registra como ADR en relayd):',
36
+ 'This is a structured question: Reply only sends text. To record the chosen option as an ADR the swarm respects, use Decide.': 'Es una pregunta estructurada: Reply sólo envía texto. Para registrar la opción elegida como un ADR que el enjambre respete, usa Decide.',
37
+ "Comal's delegation lacks the {v0} capability: relayd will reject {v1} (NOT_DELEGATED). The human must issue a delegation with it.": 'La delegación de Comal no tiene la capacidad {v0}: relayd rechazará {v1} (NOT_DELEGATED). El humano debe emitir una delegación con ella.',
38
+ 'Structured question (agent text, untrusted): {v0}': 'Pregunta estructurada (texto del agente, no confiable): {v0}',
39
+ 'Options:': 'Opciones:',
40
+ ' {v0}. {v1}{v2}': ' {v0}. {v1}{v2}',
41
+ ' ← chosen': ' ← elegida',
42
+ ' · recommended by the agent': ' · la recomienda el agente',
43
+ ' consequences: {v0}': ' consecuencias: {v0}',
44
+ 'Agent recommendation: {v0} — {v1}': 'Recomendación del agente: {v0} — {v1}',
45
+ 'Evidence cited (references, not verified): {v0}': 'Evidencia citada (referencias, sin verificar): {v0}',
46
+ 'Scope: {v0} · Reversible: {v1} · Cost of waiting: {v2}': 'Alcance: {v0} · Reversible: {v1} · Coste de esperar: {v2}',
47
+ 'yes': 'sí',
48
+ 'not stated': 'no indicado',
49
+ 'Record a decision in open-relay · decision.propose + decision.record + msg.reply': 'Registrar una decisión en open-relay · decision.propose + decision.record + msg.reply',
50
+ 'Question {v0} from {v1} · swarm {v2}': 'Pregunta {v0} de {v1} · swarm {v2}',
51
+ 'The message is not a structured question (no decision block): Decide will fail. Use Reply.': 'El mensaje no es una pregunta estructurada (sin bloque decision): Decide fallará. Usa Reply.',
52
+ 'Option «{v0}» is not one of the question\'s options: Decide will fail.': 'La opción «{v0}» no es una de las de la pregunta: Decide fallará.',
53
+ 'ADR that Comal drafted and relayd will record (accepted, signed «operator via comal»):': 'ADR que redactó Comal y registrará relayd (aceptado, firmado «operador vía comal»):',
54
+ ' Title: {v0}': ' Título: {v0}',
55
+ ' Scope: {v0}': ' Alcance: {v0}',
56
+ ' Answers: {v0}': ' Responde a: {v0}',
57
+ ' Context:': ' Contexto:',
58
+ ' Decision:': ' Decisión:',
59
+ ' Discarded alternatives:': ' Alternativas descartadas:',
60
+ ' Consequences:': ' Consecuencias:',
61
+ ' ADR digest: {v0}': ' Digest del ADR: {v0}',
62
+ 'Reply that carries the ADR (msg.reply with decision_id; {v0} characters · sha256 {v1}…):': 'Respuesta que lleva el ADR (msg.reply con decision_id; {v0} caracteres · sha256 {v1}…):',
63
+ 'Steps: 1. decision.propose (proposed) · 2. decision.record (accepted, in force) · 3. msg.reply with decision_id{v0}': 'Pasos: 1. decision.propose (propuesto) · 2. decision.record (aceptado, vigente) · 3. msg.reply con decision_id{v0}',
64
+ ' · 4. msg.ack': ' · 4. msg.ack',
65
+ 'Already recorded for this question: {v0}': 'Ya registrado para esta pregunta: {v0}',
66
+ 'Idempotency keys: {v0}': 'Claves de idempotencia: {v0}',
34
67
  'Idempotency key: {v0}': 'Clave de idempotencia: {v0}',
35
68
  'Reply in open-relay · {v0}': 'Responder en open-relay · {v0}',
36
69
  'relayd does not know message {v0}.': 'relayd no conoce el mensaje {v0}.',
@@ -76,15 +109,20 @@ const block = text => String(text).split('\n').map(line => ` │ ${line}`).join
76
109
  const fromLabel = message => message?.from?.role ? t('role {v0}', { v0: one(message.from.role) }) : message?.from?.kind ? one(message.from.kind) : t('operator');
77
110
  const bodyOf = message => message.pruned ? t('(body pruned by retention)') : quoted(message.body, 300);
78
111
 
79
- /** Un error de relayd queda con su código (INVALID_INPUT, NOT_FOUND…) en la conversación y en el recibo. */
80
- function relayFailure(method, error) {
112
+ /** Un error de relayd queda con su código (INVALID_INPUT, NOT_DELEGATED…) en la conversación y en el recibo. */
113
+ function relayFailure(method, error, extra = '') {
81
114
  if (!error?.rpc) return error;
82
- return Object.assign(new Error(`relayd rejected ${method} (${error.code}): ${error.message}`), { code: error.code, retryable: error.retryable === true });
115
+ return Object.assign(new Error(`relayd rejected ${method} (${error.code}): ${error.message}${extra}`), { code: error.code, retryable: error.retryable === true, ...(error.capability ? { capability: error.capability } : {}) });
116
+ }
117
+ /** Aviso en la tarjeta si la delegación vista en el hello no trae la capacidad que el método exige. */
118
+ function capabilityWarnings(caps, methods, warnings) {
119
+ if (!Array.isArray(caps)) return;
120
+ for (const method of methods) { const need = METHOD_CAPS[method]; if (need && !caps.includes(need)) warnings.push(t("Comal's delegation lacks the {v0} capability: relayd will reject {v1} (NOT_DELEGATED). The human must issue a delegation with it.", { v0: need, v1: method })); }
83
121
  }
84
122
 
85
123
 
86
124
  // ── Reply ────────────────────────────────────────────────────────────────────
87
- export async function replySummary(call, { message_id, body, ack = false, decision_note }, signal) {
125
+ export async function replySummary(call, { message_id, body, ack = false, decision_note }, signal, { caps = () => null } = {}) {
88
126
  const warnings = [], lines = [], message = await messageOrNull(call, message_id, signal);
89
127
  if (!message) warnings.push(t('relayd does not know message {v0}: the reply will fail.', { v0: one(message_id) }));
90
128
  const swarm = message?.swarm;
@@ -93,6 +131,8 @@ export async function replySummary(call, { message_id, body, ack = false, decisi
93
131
  if (message) {
94
132
  lines.push(t('Original message {v0} ({v1}), agent text, untrusted:', { v0: one(message_id), v1: one(message.kind) }), ` ${bodyOf(message)}`);
95
133
  if (message.from?.kind === 'operator' || !message.from?.role) warnings.push(t('The original message does not come from a role: the reply unblocks no task.'));
134
+ const structured = decisionBlock(message);
135
+ if (structured) { lines.push(...decisionLines(structured)); warnings.push(t('This is a structured question: Reply only sends text. To record the chosen option as an ADR the swarm respects, use Decide.')); }
96
136
  }
97
137
  if (linked?.link === 'park') lines.push(t('Unblocks: task {v0}{v1} · {v2} → back to the queue', { v0: one(linked.task.id), v1: linked.task.title ? ` «${one(linked.task.title)}»` : '', v2: one(linked.task.state) }));
98
138
  else if (linked) { lines.push(t('Task: {v0} ({v1}) already woke up from this message.', { v0: one(linked.task.id), v1: one(linked.task.state) })); warnings.push(t('Task {v0} is no longer blocked by this question.', { v0: one(linked.task.id) })); }
@@ -100,7 +140,8 @@ export async function replySummary(call, { message_id, body, ack = false, decisi
100
140
  if (message && !delivery) warnings.push(t("The delivery is no longer pending in the operator's inbox (acknowledged or expired)."));
101
141
  lines.push(t('Exact text to send ({v0} characters · sha256 {v1}…):', { v0: body.length, v1: sha256(body).slice(0, 16) }), block(body));
102
142
  lines.push(t('Confirm delivery (msg.ack): {v0}', { v0: ack ? t('yes, the message is marked read') : t('no') }));
103
- if (decision_note) lines.push(t('Decision note (only in the Comal receipt; phase 3 will store it as an ADR):'), block(decision_note));
143
+ if (decision_note) lines.push(t('Decision note (only in the Comal receipt; Decide records it as an ADR in relayd):'), block(decision_note));
144
+ capabilityWarnings(caps(), ack ? ['msg.reply', 'msg.ack'] : ['msg.reply'], warnings);
104
145
  lines.push(t('Idempotency key: {v0}', { v0: idempotencyKey('reply', { in_reply_to: message_id, body }) }));
105
146
  return { title: t('Reply in open-relay · {v0}', { v0: `msg.reply${ack ? ' + msg.ack' : ''}` }), lines, warnings, editable: 'body' };
106
147
  }
@@ -123,18 +164,19 @@ export async function reply(call, mutate, { message_id, body, ack = false, decis
123
164
  sent: { in_reply_to: message_id, reply_message_id: sent.message_id, deliveries: sent.deliveries, head_seq: sent.head_seq, idempotency_key: key, body, body_chars: body.length, body_sha256: sha256(body) },
124
165
  ack: acked, task,
125
166
  decision_note: decision_note ?? null,
126
- ...(decision_note ? { decision_note_status: 'Stored only in the Comal receipt (phase 2). Phase 3 will record it as an ADR in relayd (open-relay v2.8).' } : {}),
167
+ ...(decision_note ? { decision_note_status: 'Stored only in the Comal receipt. To record a structured decision as an ADR in relayd, use Decide.' } : {}),
127
168
  ...(sent.outcome === 'idempotent_replay' ? { note: 'relayd recognized the key: this reply had already been sent and was not duplicated.' } : {}),
128
169
  };
129
170
  }
130
171
 
131
172
  // ── Ack ──────────────────────────────────────────────────────────────────────
132
173
  const ackTarget = ({ delivery_id, message_id }) => delivery_id || message_id;
133
- export async function ackSummary(call, input, signal) {
174
+ export async function ackSummary(call, input, signal, { caps = () => null } = {}) {
134
175
  const target = ackTarget(input), message_id = input.message_id || messageOfDelivery(input.delivery_id), warnings = [];
135
176
  const message = await messageOrNull(call, message_id, signal);
136
177
  if (!message) warnings.push(t('relayd does not know message {v0}.', { v0: one(message_id) }));
137
178
  else if (!(await pendingDelivery(call, message.swarm, message_id, signal))) warnings.push(t('The delivery is no longer pending: acknowledging it changes nothing.'));
179
+ capabilityWarnings(caps(), ['msg.ack'], warnings);
138
180
  return { title: t('Mark as read in open-relay · msg.ack'), warnings, lines: [
139
181
  t('Delivery: {v0}', { v0: one(target) }),
140
182
  ...(message ? [t('Message {v0} ({v1}) from {v2} · swarm {v3}, agent text, untrusted:', { v0: one(message_id), v1: one(message.kind), v2: fromLabel(message), v3: one(message.swarm) }), ` ${bodyOf(message)}`] : []),
@@ -168,9 +210,10 @@ function planLines(got, warnings) {
168
210
  return lines;
169
211
  }
170
212
 
171
- export async function approveSummary(call, { proposal_id, digest: wanted }, signal) {
213
+ export async function approveSummary(call, { proposal_id, digest: wanted }, signal, { caps = () => null } = {}) {
172
214
  const warnings = [t('Applies immediately: on approval, relayd creates the roles, grants and tasks of the plan with no further step.')];
173
215
  const got = await planOrNull(call, proposal_id, signal);
216
+ capabilityWarnings(caps(), ['plan.approve'], warnings);
174
217
  if (!got) return { title: t('Approve plan in open-relay · plan.approve'), lines: [t('Proposal {v0}', { v0: one(proposal_id) }), t('Digest: {v0}', { v0: one(wanted) })], warnings: [...warnings, t('relayd does not know proposal {v0}: the approval will fail.', { v0: one(proposal_id) })] };
175
218
  const lines = planLines(got, warnings), current = got.proposal?.digest;
176
219
  lines.push(t('Approved digest: {v0}', { v0: one(wanted) }) + (current === wanted ? t(' (matches the current one)') : ''));
@@ -184,8 +227,9 @@ export async function approve(mutate, { proposal_id, digest: wanted }, signal) {
184
227
  catch (error) { throw relayFailure('plan.approve', error); }
185
228
  }
186
229
 
187
- export async function rejectSummary(call, { proposal_id, reason }, signal) {
230
+ export async function rejectSummary(call, { proposal_id, reason }, signal, { caps = () => null } = {}) {
188
231
  const warnings = [], got = await planOrNull(call, proposal_id, signal);
232
+ capabilityWarnings(caps(), ['plan.reject'], warnings);
189
233
  const lines = got ? planLines(got, warnings) : [t('Proposal {v0}', { v0: one(proposal_id) })];
190
234
  if (!got) warnings.push(t('relayd does not know proposal {v0}: the rejection will fail.', { v0: one(proposal_id) }));
191
235
  lines.push(t('Exact reason the lead receives (it is woken up with it):'), block(reason));
@@ -203,3 +247,104 @@ function decision(result, key) {
203
247
  proposal: { proposal_id: proposal.proposal_id, status: proposal.status, approver: proposal.approver ?? null, digest: proposal.digest, reason: proposal.reason ? excerpt(proposal.reason, 500) : null, tasks: proposal.tasks, roles: proposal.roles },
204
248
  steps: (result.steps || []).slice(0, 40).map(step => ({ kind: step.kind, id: step.id, method: step.method, action: step.action, result: step.result ?? null })) };
205
249
  }
250
+
251
+ // ── Decide (fase 3) ─────────────────────────────────────────────────────────
252
+ // Respuesta a una pregunta estructurada que queda como ADR en relayd. El HOST redacta
253
+ // el ADR (decision.mjs) con la pregunta tal como la guarda relayd, la opción elegida y
254
+ // la nota; la tarjeta lo muestra entero con su digest y sólo el humano la aprueba
255
+ // (confirm:'always'). Tres mutaciones con claves estables derivadas de la misma
256
+ // entrada: un reintento de la misma tarjeta retoma donde quedó sin duplicar nada.
257
+ const yesNo = value => value ? t('yes') : t('no');
258
+ /** Líneas legibles del bloque `decision` (texto del agente, ya limpio y acotado). */
259
+ export function decisionLines(block, chosen = null) {
260
+ const lines = [t('Structured question (agent text, untrusted): {v0}', { v0: `«${block.question}»` }), t('Options:')];
261
+ block.options.forEach((item, index) => {
262
+ lines.push(t(' {v0}. {v1}{v2}', { v0: index + 1, v1: `«${item.label}»`, v2: `${item.label === chosen?.label ? t(' ← chosen') : ''}${item.label === block.recommendation?.option ? t(' · recommended by the agent') : ''}` }));
263
+ if (item.consequences) lines.push(t(' consequences: {v0}', { v0: excerpt(item.consequences, 400) }));
264
+ });
265
+ if (block.recommendation) lines.push(t('Agent recommendation: {v0} — {v1}', { v0: `«${block.recommendation.option}»`, v1: excerpt(block.recommendation.why, 400) }));
266
+ if (block.evidence.length) lines.push(t('Evidence cited (references, not verified): {v0}', { v0: block.evidence.map(item => `${item.kind}:${one(item.ref)}`).join(' · ') }));
267
+ lines.push(t('Scope: {v0} · Reversible: {v1} · Cost of waiting: {v2}', { v0: block.scope, v1: yesNo(block.reversible), v2: block.cost_of_waiting ? excerpt(block.cost_of_waiting, 300) : t('not stated') }));
268
+ return lines;
269
+ }
270
+ const decideBase = ({ message_id, option, body, decision_note, scope }) => digest({ action: 'decide', message_id, option, body, decision_note: decision_note ?? null, scope: scope ?? null }).slice(0, 48);
271
+ export const decideKeys = input => { const base = decideBase(input); return { propose: `comal-adr-propose-${base}`, record: `comal-adr-record-${base}`, reply: `comal-decide-reply-${base}`, ack: idempotencyKey('ack', { delivery_id: input.message_id }) }; };
272
+ const scopeText = scope => scope.id ? `${scope.kind}:${one(scope.id)}` : scope.kind;
273
+
274
+ /** Lo que la tarjeta y la ejecución comparten: mensaje, bloque, opción, tarea, alcance y ADR. */
275
+ async function prepareDecision(call, { message_id, option, decision_note, scope }, signal) {
276
+ const message = await messageOrNull(call, message_id, signal);
277
+ if (!message) return { message: null };
278
+ const block = decisionBlock(message), chosen = chosenOption(block, option);
279
+ const linked = block ? await taskForMessage(call, message.swarm, message_id, signal) : null;
280
+ const { scope: adrScopeValue, note } = block ? adrScope(block, { scope, message, task: linked?.task }) : { scope: null, note: null };
281
+ const adr = chosen ? draftAdr({ message, block, option, decision_note, scope: adrScopeValue }) : null;
282
+ return { message, block, chosen, linked, adr, scopeNote: note };
283
+ }
284
+ async function existingAdrs(call, message, signal) {
285
+ try { const { decisions = [] } = await call('decision.list', { swarm_id: message.swarm, limit: 200 }, signal); return decisions.filter(item => item.answers === message.id); }
286
+ catch (error) { if (error.rpc) return []; throw error; }
287
+ }
288
+
289
+ export async function decideSummary(call, input, signal, { caps = () => null } = {}) {
290
+ const { message_id, option, body, ack = false } = input, warnings = [], lines = [];
291
+ const title = t('Record a decision in open-relay · decision.propose + decision.record + msg.reply');
292
+ const { message, block: structured, chosen, linked, adr, scopeNote } = await prepareDecision(call, input, signal);
293
+ if (!message) return { title, lines: [t('Delivery: {v0}', { v0: one(message_id) })], warnings: [t('relayd does not know message {v0}: the reply will fail.', { v0: one(message_id) })] };
294
+ lines.push(t('Question {v0} from {v1} · swarm {v2}', { v0: one(message_id), v1: fromLabel(message), v2: one(message.swarm) }));
295
+ if (!structured) warnings.push(t('The message is not a structured question (no decision block): Decide will fail. Use Reply.'));
296
+ else {
297
+ lines.push(...decisionLines(structured, chosen));
298
+ if (!chosen) warnings.push(t('Option «{v0}» is not one of the question\'s options: Decide will fail.', { v0: one(option) }));
299
+ }
300
+ if (linked?.link === 'park') lines.push(t('Unblocks: task {v0}{v1} · {v2} → back to the queue', { v0: one(linked.task.id), v1: linked.task.title ? ` «${one(linked.task.title)}»` : '', v2: one(linked.task.state) }));
301
+ if (scopeNote) warnings.push(scopeNote);
302
+ if (adr) {
303
+ lines.push(t('ADR that Comal drafted and relayd will record (accepted, signed «operator via comal»):'));
304
+ lines.push(t(' Title: {v0}', { v0: adr.title }), t(' Scope: {v0}', { v0: scopeText(adr.scope) }), t(' Answers: {v0}', { v0: one(adr.answers) }));
305
+ lines.push(t(' Context:'), block4(adr.context), t(' Decision:'), block4(adr.decision));
306
+ if (adr.alternatives.length) lines.push(t(' Discarded alternatives:'), block4(adr.alternatives.map(item => `${item.option}: ${item.why_rejected}`).join('\n')));
307
+ lines.push(t(' Consequences:'), block4(adr.consequences), t(' ADR digest: {v0}', { v0: adrDigest(adr) }));
308
+ for (const item of (await existingAdrs(call, message, signal)).slice(0, 4)) warnings.push(t('Already recorded for this question: {v0}', { v0: `${one(item.id)} (${one(item.status)}${item.in_force ? ', in force' : ''}) «${one(item.title)}»` }));
309
+ }
310
+ lines.push(t('Reply that carries the ADR (msg.reply with decision_id; {v0} characters · sha256 {v1}…):', { v0: body.length, v1: sha256(body).slice(0, 16) }), block(body));
311
+ lines.push(t('Steps: 1. decision.propose (proposed) · 2. decision.record (accepted, in force) · 3. msg.reply with decision_id{v0}', { v0: ack ? t(' · 4. msg.ack') : '' }));
312
+ if (message && !(await pendingDelivery(call, message.swarm, message_id, signal))) warnings.push(t("The delivery is no longer pending in the operator's inbox (acknowledged or expired)."));
313
+ capabilityWarnings(caps(), ['decision.propose', 'decision.record', 'msg.reply', ...(ack ? ['msg.ack'] : [])], warnings);
314
+ const keys = decideKeys(input);
315
+ lines.push(t('Idempotency keys: {v0}', { v0: [keys.propose, keys.record, keys.reply].join(' · ') }));
316
+ return { title, lines, warnings, editable: 'body' };
317
+ }
318
+ const block4 = text => String(text).split('\n').map(line => ` │ ${line}`).join('\n');
319
+ const fail = (code, message) => Object.assign(new Error(message), { code });
320
+
321
+ export async function decide(call, mutate, input, signal) {
322
+ const { message_id, option, body, ack = false } = input, keys = decideKeys(input);
323
+ const { message, block: structured, chosen, linked, adr } = await prepareDecision(call, input, signal);
324
+ if (!message) throw fail('NOT_FOUND', `relayd does not know message ${one(message_id)}.`);
325
+ if (!structured) throw fail('DECISION_NOT_STRUCTURED', `Message ${one(message_id)} is not a structured question (it has no decision block): use Reply.`);
326
+ if (!chosen || !adr) throw fail('DECISION_UNKNOWN_OPTION', `«${one(option)}» is not one of the options: ${structured.options.map(item => `«${item.label}»`).join(', ')}.`);
327
+ let proposed, recorded, sent;
328
+ try { proposed = await mutate('decision.propose', { idempotency_key: keys.propose, adr }, signal); }
329
+ catch (error) { throw relayFailure('decision.propose', error); }
330
+ const id = proposed.decision?.id;
331
+ try { recorded = await mutate('decision.record', { idempotency_key: keys.record, decision_id: id }, signal); }
332
+ catch (error) { throw relayFailure('decision.record', error, ` (ADR ${id} stays proposed; retrying the same card resumes without duplicating it)`); }
333
+ try { sent = await mutate('msg.reply', { idempotency_key: keys.reply, in_reply_to: message_id, body, decision_id: id }, signal); }
334
+ catch (error) { throw relayFailure('msg.reply', error, ` (ADR ${id} is recorded and in force; retrying the same card sends the reply without duplicating anything)`); }
335
+ let acked = null;
336
+ if (ack) {
337
+ try { const result = await mutate('msg.ack', { idempotency_key: keys.ack, delivery_id: message_id }, signal); acked = { status: 'applied', outcome: result.outcome, delivery_id: result.delivery_id, state: result.state }; }
338
+ catch (error) { acked = { status: 'failed', code: error.code || 'ERROR', message: String(error.message).slice(0, 300) }; }
339
+ }
340
+ const view = recorded.decision || proposed.decision || {};
341
+ const replayed = [proposed, recorded, sent].every(item => item.outcome === 'idempotent_replay');
342
+ return {
343
+ truncated: false, status: replayed ? 'replayed' : 'applied', outcome: sent.outcome,
344
+ decision: { id, status: view.status, in_force: view.in_force, title: view.title, scope: view.scope, answers: view.answers ?? null, proposed_via: view.proposed_via ?? null, accepted_via: view.accepted_via ?? null, adr_digest: adrDigest(adr), propose_outcome: proposed.outcome, record_outcome: recorded.outcome },
345
+ sent: { in_reply_to: message_id, decision_id: id, reply_message_id: sent.message_id, deliveries: sent.deliveries, head_seq: sent.head_seq, body, body_chars: body.length, body_sha256: sha256(body) },
346
+ ack: acked, task: linked ? { id: linked.task.id, state: linked.task.state, link: linked.link } : null,
347
+ idempotency_keys: keys,
348
+ ...(replayed ? { note: 'relayd recognized the keys: this decision and its reply had already been recorded and were not duplicated.' } : {}),
349
+ };
350
+ }
@@ -1,57 +1,101 @@
1
1
  import net from 'node:net';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { constants } from 'node:fs';
4
- import { open } from 'node:fs/promises';
5
+ import { open, stat } from 'node:fs/promises';
5
6
 
6
7
  // Cliente JSON-RPC 2.0 por líneas sobre el socket administrativo de relayd
7
- // (`<control>/admin/relay.sock`). Las listas blancas de métodos viven aquí: 16
8
- // lecturas (`call`) y, desde la fase 2, cuatro mutaciones (`mutate`) que el plugin
9
- // sólo usa tras la tarjeta de confirmación humana. Cualquier otro método se rechaza
10
- // sin llegar al socket: ningún job, swarm.create, grant ni sesión.
8
+ // (`<control>/admin/relay.sock`). Desde la fase 3 (open-relay ≥ 2.8) se autentica con
9
+ // una sesión DELEGADA (`relay session issue-delegate --client comal`), nunca con
10
+ // `admin.token`: relayd firma lo que hace como «operador vía comal» y sólo admite los
11
+ // métodos de sus capacidades. Las listas blancas de métodos siguen aquí: 19 lecturas
12
+ // (`call`) y seis mutaciones (`mutate`) que el plugin sólo usa tras la tarjeta de
13
+ // confirmación humana. Cualquier otro método se rechaza sin llegar al socket.
11
14
  export const READ_METHODS = Object.freeze(new Set([
12
15
  'daemon.status', 'daemon.version', 'swarm.list', 'swarm.status', 'task.get', 'task.list',
13
16
  'msg.inbox', 'msg.get', 'msg.wait', 'plan.list', 'plan.get', 'job.list', 'job.get', 'job.logs',
14
- 'receipt.get', 'events.since',
17
+ 'receipt.get', 'events.since', 'events.wait', 'decision.list', 'decision.get',
15
18
  ]));
16
- export const WRITE_METHODS = Object.freeze(new Set(['msg.reply', 'msg.ack', 'plan.approve', 'plan.reject']));
17
- // El alcance de idempotencia del canal admin es compartido ("operator") por todos sus
18
- // clientes: las claves de Comal llevan prefijo propio y caben en los 128 de relayd.
19
+ export const WRITE_METHODS = Object.freeze(new Set(['msg.reply', 'msg.ack', 'plan.approve', 'plan.reject', 'decision.propose', 'decision.record']));
20
+ // Capacidad de la delegación que relayd exige para cada mutación (lecturas: `read`).
21
+ export const METHOD_CAPS = Object.freeze({ 'msg.reply': 'converse', 'msg.ack': 'converse', 'plan.approve': 'plans', 'plan.reject': 'plans', 'decision.propose': 'decide', 'decision.record': 'decide' });
22
+ export const RECOMMENDED_CAPS = 'read,converse,plans,decide';
23
+ // El `client` del hello debe ser exactamente el de la delegación: así un token emitido
24
+ // para otro programa (p. ej. la web) no sirve a Comal.
25
+ export const DELEGATE_CLIENT = 'comal';
26
+ // Las claves de idempotencia son del alcance de la sesión delegada; el prefijo comal-
27
+ // se mantiene para reconocerlas en el log de relayd.
19
28
  export const IDEMPOTENCY_KEY = /^comal-[A-Za-z0-9][A-Za-z0-9_.:-]{0,121}$/;
20
29
  export const PROTOCOL_VERSION = 1;
30
+ const TOKEN_FORMAT = /^orly_[0-9a-f]{64}$/;
21
31
  // relayd acota cada frame a 1 MiB por omisión; las respuestas de lectura del CLI admiten 16×.
22
32
  const MAX_RESPONSE_BYTES = 16 << 20;
23
33
  const fail = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
24
34
 
25
- export function controlPaths(controlDir) {
26
- const root = path.resolve(controlDir);
27
- return { root, admin: path.join(root, 'admin'), socket: path.join(root, 'admin', 'relay.sock'), token: path.join(root, 'admin', 'admin.token') };
35
+ export function controlPaths(controlDir, tokenFile) {
36
+ const root = path.resolve(controlDir), admin = path.join(root, 'admin');
37
+ return { root, admin, socket: path.join(admin, 'relay.sock'), adminToken: path.join(admin, 'admin.token'), token: tokenFile ? path.resolve(tokenFile) : path.join(root, 'comal.token') };
28
38
  }
29
39
 
30
- /** Lee el token en cada conexión: relayd lo reescribe en cada arranque. Nunca se expone. */
31
- async function readToken(file) {
40
+ const shellWord = value => /^[A-Za-z0-9_./:=@%+-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
41
+ const principal = () => { try { return os.userInfo().username || 'operator'; } catch { return 'operator'; } };
42
+ /** El comando exacto que el humano corre (una vez) para emitir el token de Comal. */
43
+ export function issueCommand(paths, caps = RECOMMENDED_CAPS) {
44
+ return `relay --control-dir ${shellWord(paths.root)} session issue-delegate --principal ${shellWord(principal())} --client ${DELEGATE_CLIENT} --caps ${caps} --ttl 30d --out ${shellWord(paths.token)}`;
45
+ }
46
+ const reissue = paths => `Remove ${paths.token} and issue a new one: ${issueCommand(paths)}`;
47
+
48
+ /**
49
+ * Lee el token delegado en cada conexión (el humano puede reemitirlo sin recargar el
50
+ * plugin). Nunca lee `admin.token`: ni como respaldo ni si la ruta configurada lo es.
51
+ * El valor nunca sale de aquí salvo hacia el hello.
52
+ */
53
+ async function readToken(paths) {
54
+ if (paths.token === paths.adminToken || path.dirname(paths.token) === paths.admin) throw fail('RELAY_ADMIN_TOKEN_REFUSED', `Comal never authenticates with relayd's admin token. Use a delegate token outside ${paths.admin}: ${issueCommand({ ...paths, token: path.join(paths.root, 'comal.token') })}`);
32
55
  let handle;
33
56
  try {
34
- handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
57
+ handle = await open(paths.token, constants.O_RDONLY | constants.O_NOFOLLOW);
35
58
  const info = await handle.stat(), uid = process.getuid?.();
36
- if (!info.isFile() || info.size > 4096 || (uid !== undefined && info.uid !== uid) || (info.mode & 0o077)) throw fail('RELAY_TOKEN_UNSAFE', "relayd's admin token is not a private (0600) file owned by the user.");
37
- const token = (await handle.readFile('utf8')).trim();
38
- if (!token) throw fail('RELAY_UNAVAILABLE', "relayd's admin token is empty.");
59
+ if (!info.isFile() || info.size > 4096 || (uid !== undefined && info.uid !== uid) || (info.mode & 0o077)) throw fail('RELAY_TOKEN_UNSAFE', `The delegate token ${paths.token} is not a private (0600) file owned by the user.`);
60
+ // Un enlace duro a admin.token también se rechaza (sólo se mira su inodo, nunca su contenido).
61
+ const admin = await stat(paths.adminToken).catch(() => null);
62
+ if (admin && admin.ino === info.ino && admin.dev === info.dev) throw fail('RELAY_ADMIN_TOKEN_REFUSED', `${paths.token} is relayd's admin token, not a delegate session. ${reissue(paths)}`);
63
+ const token = (await handle.readFile('utf8')).replace(/\r?\n$/, '');
64
+ if (!TOKEN_FORMAT.test(token)) throw fail('RELAY_TOKEN_INVALID', `The delegate token ${paths.token} does not have the orly_<64 hex> format. ${reissue(paths)}`);
39
65
  return token;
40
66
  } catch (error) {
41
- if (error.code === 'ENOENT') throw fail('RELAY_UNAVAILABLE', 'relayd is not running in that control directory (the admin token is missing).');
67
+ if (error.code === 'ENOENT') {
68
+ const running = await stat(paths.adminToken).then(() => true, () => false);
69
+ throw fail('RELAY_DELEGATE_MISSING', running
70
+ ? `Comal has no delegate token for relayd at ${paths.token} (Comal never uses admin.token). Ask the human to issue it once: ${issueCommand(paths)}`
71
+ : `No delegate token at ${paths.token}, and relayd does not seem to be running in ${paths.root}. Start relayd, then issue the token: ${issueCommand(paths)}`);
72
+ }
73
+ if (error.code === 'ELOOP') throw fail('RELAY_TOKEN_UNSAFE', `The delegate token ${paths.token} is a symlink; use a regular 0600 file.`);
42
74
  throw error;
43
75
  } finally { await handle?.close(); }
44
76
  }
45
77
 
78
+ /** Mensaje legible para un error de relayd; NOT_DELEGATED y UNAUTHORIZED dicen qué hacer. */
79
+ function rpcFailure(method, data, paths) {
80
+ const code = data.code || 'RELAY_ERROR', text = String(data.message || 'relayd error').slice(0, 500);
81
+ if (code === 'NOT_DELEGATED') {
82
+ const capability = METHOD_CAPS[method] || /capacidad (\w+)/.exec(text)?.[1] || (READ_METHODS.has(method) ? 'read' : null);
83
+ return fail(code, `${text} — Comal's delegate session cannot do ${method}${capability ? ` (missing capability: ${capability})` : ''}. Not retried: ask the human for a delegation with that capability. ${reissue(paths)}`, { retryable: false, rpc: true, capability });
84
+ }
85
+ // En una llamada, UNAUTHORIZED con «sesión revocada/expirada» es la delegación; en decision.get es «no existe».
86
+ if (code === 'UNAUTHORIZED' && (method === 'daemon.hello' || /revoc|expir/i.test(text))) return fail(code, `relayd rejected Comal's delegate token (${text}): it was revoked, expired, or issued for a client other than "${DELEGATE_CLIENT}". ${reissue(paths)}`, { retryable: false, rpc: true });
87
+ return fail(code, text, { retryable: data.retryable === true, rpc: true });
88
+ }
89
+
46
90
  /** Una conexión autenticada. Las esperas largas usan una conexión propia. */
47
91
  export class RelayConnection {
48
- constructor({ controlDir, client = 'comal', timeoutMs = 10000, connect = options => net.createConnection(options) }) {
49
- Object.assign(this, { paths: controlPaths(controlDir), client, timeoutMs, connect });
92
+ constructor({ controlDir, tokenFile, client = DELEGATE_CLIENT, timeoutMs = 10000, connect = options => net.createConnection(options) }) {
93
+ Object.assign(this, { paths: controlPaths(controlDir, tokenFile), client, timeoutMs, connect });
50
94
  this.pending = new Map(); this.nextId = 1; this.closed = false; this.socket = null; this.session = null;
51
95
  }
52
96
  async open(signal) {
53
97
  signal?.throwIfAborted();
54
- const token = await readToken(this.paths.token);
98
+ const token = await readToken(this.paths);
55
99
  const socket = this.socket = this.connect({ path: this.paths.socket });
56
100
  let buffer = Buffer.alloc(0);
57
101
  await new Promise((resolve, reject) => {
@@ -75,10 +119,16 @@ export class RelayConnection {
75
119
  });
76
120
  socket.on('error', error => this.#fail(fail('RELAY_DISCONNECTED', `Connection to relayd lost (${error.code || error.message}).`)));
77
121
  socket.on('close', () => this.#fail(fail('RELAY_DISCONNECTED', 'relayd closed the connection.')));
78
- try { this.session = await this.#request('daemon.hello', { protocol_versions: [PROTOCOL_VERSION], token, client: this.client }, { signal }); }
79
- catch (error) { this.close(); throw error; }
122
+ try {
123
+ const session = await this.#request('daemon.hello', { protocol_versions: [PROTOCOL_VERSION], token, client: this.client }, { signal });
124
+ // Sin `delegate` el token es del operador directo (p. ej. una copia de admin.token): no se usa.
125
+ if (!session?.delegate) throw fail('RELAY_ADMIN_TOKEN_REFUSED', `The token in ${this.paths.token} is a direct operator token, not a delegate session; Comal only operates through a delegation. ${reissue(this.paths)}`);
126
+ this.session = session;
127
+ } catch (error) { this.close(); throw error; }
80
128
  return this.session;
81
129
  }
130
+ /** Lo que relayd dijo de la delegación en el hello: principal, client, caps, persistent. */
131
+ get delegate() { return this.session?.delegate || null; }
82
132
  #fail(error) {
83
133
  if (this.closed) return; this.closed = true; this.error = error;
84
134
  for (const entry of this.pending.values()) entry.finish(null, error);
@@ -89,7 +139,7 @@ export class RelayConnection {
89
139
  if (!READ_METHODS.has(method)) return Promise.reject(fail('RELAY_METHOD_FORBIDDEN', `Method not allowed for reading: ${method}`));
90
140
  return this.#request(method, params, { signal, timeoutMs });
91
141
  }
92
- /** Sólo las cuatro mutaciones de la lista blanca y siempre con una clave de idempotencia de Comal. */
142
+ /** Sólo las mutaciones de la lista blanca y siempre con una clave de idempotencia de Comal. */
93
143
  mutate(method, params = {}, { signal, timeoutMs } = {}) {
94
144
  if (!WRITE_METHODS.has(method)) return Promise.reject(fail('RELAY_METHOD_FORBIDDEN', `Method not allowed for Comal: ${method}`));
95
145
  if (typeof params?.idempotency_key !== 'string' || !IDEMPOTENCY_KEY.test(params.idempotency_key)) return Promise.reject(fail('RELAY_IDEMPOTENCY_KEY', 'Every mutation needs a comal-… idempotency_key of up to 128 characters.'));
@@ -103,7 +153,7 @@ export class RelayConnection {
103
153
  const finish = (message, error) => {
104
154
  clearTimeout(timer); signal?.removeEventListener('abort', abort);
105
155
  if (error) return reject(error);
106
- if (message.error) { const data = message.error.data || {}; return reject(fail(data.code || 'RELAY_ERROR', String(data.message || message.error.message || 'relayd error').slice(0, 500), { retryable: data.retryable === true, rpc: true })); }
156
+ if (message.error) return reject(rpcFailure(method, { message: message.error.message, ...(message.error.data || {}) }, this.paths));
107
157
  resolve(message.result);
108
158
  };
109
159
  const abort = () => { if (this.pending.delete(id)) finish(null, signal.reason); };
@@ -116,21 +166,29 @@ export class RelayConnection {
116
166
  close() { this.#fail(fail('RELAY_DISCONNECTED', 'Connection closed by Comal.')); }
117
167
  }
118
168
 
119
- // Errores tras los cuales conviene reconectar (relayd reiniciado, sesión vencida, token nuevo).
120
- export const reconnectable = error => ['RELAY_DISCONNECTED', 'RELAY_UNAVAILABLE', 'RELAY_TIMEOUT', 'UNAUTHORIZED', 'RELAY_PROTOCOL'].includes(error?.code);
169
+ // Errores tras los cuales conviene reconectar (relayd reiniciado o recuperándose).
170
+ // UNAUTHORIZED, NOT_DELEGATED y los del archivo del token necesitan al humano.
171
+ export const reconnectable = error => ['RELAY_DISCONNECTED', 'RELAY_UNAVAILABLE', 'RELAY_TIMEOUT', 'RELAY_PROTOCOL', 'UNAVAILABLE'].includes(error?.code);
172
+ // Errores que ninguna espera arregla: hace falta otra delegación o que el humano corrija el token.
173
+ export const needsHuman = error => ['NOT_DELEGATED', 'UNAUTHORIZED', 'RELAY_DELEGATE_MISSING', 'RELAY_ADMIN_TOKEN_REFUSED', 'RELAY_TOKEN_UNSAFE', 'RELAY_TOKEN_INVALID'].includes(error?.code);
121
174
 
122
175
  /** Conexión de consultas compartida, con reconexión perezosa y un reintento (las lecturas son idempotentes). */
123
176
  export class RelayClient {
124
- constructor({ controlDir, client = 'comal', timeoutMs = 10000, connect }) {
125
- Object.assign(this, { controlDir, client, timeoutMs, connect });
126
- this.connection = null; this.connecting = null; this.closed = false; this.connects = 0; this.lastError = null;
177
+ constructor({ controlDir, tokenFile, client = DELEGATE_CLIENT, timeoutMs = 10000, connect }) {
178
+ Object.assign(this, { controlDir, tokenFile, client, timeoutMs, connect, paths: controlPaths(controlDir, tokenFile) });
179
+ this.connection = null; this.connecting = null; this.closed = false; this.connects = 0; this.lastError = null; this.session = null;
127
180
  }
128
- connection_options() { return { controlDir: this.controlDir, client: this.client, timeoutMs: this.timeoutMs, ...(this.connect ? { connect: this.connect } : {}) }; }
129
- /** Conexión nueva y dedicada (para msg.wait / swarm.status con if_changed). */
181
+ connection_options() { return { controlDir: this.controlDir, tokenFile: this.tokenFile, client: this.client, timeoutMs: this.timeoutMs, ...(this.connect ? { connect: this.connect } : {}) }; }
182
+ /** La delegación vista en el último hello (sin el token). */
183
+ get delegate() { return this.session?.delegate || null; }
184
+ issueCommand() { return issueCommand(this.paths); }
185
+ /** Conexión nueva y dedicada (para events.wait). */
130
186
  async dedicated(signal) {
131
187
  if (this.closed) throw fail('RELAY_CLOSED', 'open-relay plugin closed.');
132
188
  const connection = new RelayConnection(this.connection_options());
133
- await connection.open(signal); this.connects++; return connection;
189
+ try { await connection.open(signal); }
190
+ catch (error) { this.lastError = { code: error.code || 'ERROR', message: String(error.message).slice(0, 600), at: new Date().toISOString() }; throw error; }
191
+ this.connects++; this.session = connection.session; return connection;
134
192
  }
135
193
  async #shared(signal) {
136
194
  if (this.connection && !this.connection.closed) return this.connection;
@@ -148,7 +206,7 @@ export class RelayClient {
148
206
  for (let attempt = 0; ; attempt++) {
149
207
  try { const connection = await this.#shared(options.signal); const result = await connection[kind](method, params, options); this.lastError = null; return result; }
150
208
  catch (error) {
151
- this.lastError = { code: error.code || 'ERROR', message: String(error.message).slice(0, 300), at: new Date().toISOString() };
209
+ this.lastError = { code: error.code || 'ERROR', message: String(error.message).slice(0, 600), at: new Date().toISOString() };
152
210
  if (attempt || !reconnectable(error) || options.signal?.aborted) throw error;
153
211
  this.connection?.close(); this.connection = null;
154
212
  }
@@ -0,0 +1,88 @@
1
+ import { clean } from './bound.mjs';
2
+ import { digest } from '../../operations.mjs';
3
+
4
+ // Preguntas estructuradas (`message.decision`, open-relay ≥ 2.8) y el ADR que Comal
5
+ // redacta a partir de la opción elegida. Todo el bloque `decision` es texto de un
6
+ // agente: se limpia (sin controles, escapes de terminal ni bidi), se acota y se marca
7
+ // como no confiable. El ADR lo arma el HOST, determinista, con datos de relayd y lo que
8
+ // el humano aprueba en la tarjeta (opción, nota, texto de la respuesta): el modelo no
9
+ // redacta el registro ni lo puede aprobar.
10
+ const EVIDENCE_KINDS = new Set(['task', 'attempt', 'receipt', 'file', 'commit', 'message', 'decision']);
11
+ const SCOPES = new Set(['task', 'swarm', 'project']);
12
+ const line = (value, limit) => cut(clean(value).replace(/\s+/g, ' ').trim(), limit);
13
+ const text = (value, limit) => cut(clean(value).replace(/[ \t]+\n/g, '\n').trim(), limit);
14
+ function cut(value, limit) { const chars = [...value]; return chars.length > limit ? chars.slice(0, limit).join('') + '…' : value; }
15
+ /** Recorta a `max` bytes UTF-8 sin partir un carácter (relayd mide en bytes). */
16
+ export function cutBytes(value, max) {
17
+ if (Buffer.byteLength(value) <= max) return value;
18
+ let out = '', size = 0; const room = max - 3;
19
+ for (const char of value) { const bytes = Buffer.byteLength(char); if (size + bytes > room) break; out += char; size += bytes; }
20
+ return out + '…';
21
+ }
22
+
23
+ /** El bloque `decision` de un mensaje, limpio y acotado, o null si no es una pregunta estructurada. */
24
+ export function decisionBlock(message) {
25
+ const raw = message?.kind === 'question' ? message.decision : null;
26
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.options)) return null;
27
+ const options = raw.options.slice(0, 4).filter(item => item && typeof item.label === 'string').map(item => ({ label: line(item.label, 120), consequences: text(item.consequences ?? '', 1024) }));
28
+ if (!options.length) return null;
29
+ const recommendation = raw.recommendation && typeof raw.recommendation === 'object' ? { option: line(raw.recommendation.option ?? '', 120), why: text(raw.recommendation.why ?? '', 1024) } : null;
30
+ return {
31
+ untrusted: true,
32
+ question: line(raw.question ?? '', 200),
33
+ options,
34
+ recommendation,
35
+ evidence: (Array.isArray(raw.evidence) ? raw.evidence : []).slice(0, 16).map(item => ({ kind: EVIDENCE_KINDS.has(item?.kind) ? item.kind : 'other', ref: line(item?.ref ?? '', 256) })),
36
+ scope: SCOPES.has(raw.scope) ? raw.scope : 'task',
37
+ reversible: raw.reversible === true,
38
+ cost_of_waiting: text(raw.cost_of_waiting ?? '', 600),
39
+ };
40
+ }
41
+
42
+ /** Resumen de una línea para avisos y el buzón (al modelo: inglés fijo). */
43
+ export function decisionLine(block) {
44
+ if (!block) return null;
45
+ const recommended = block.recommendation?.option;
46
+ return `«${block.question}» · options: ${block.options.map(item => `«${item.label}»${item.label === recommended ? ' (recommended by the agent)' : ''}`).join(' | ')} · scope ${block.scope} · ${block.reversible ? 'reversible' : 'not reversible'}`;
47
+ }
48
+
49
+ /** La opción elegida (por su label exacto, sin distinguir espacios de más) o null. */
50
+ export const chosenOption = (block, option) => block?.options.find(item => item.label === line(option ?? '', 120)) || null;
51
+
52
+ /** Alcance del ADR: el pedido por el humano o el de la pregunta. Una pregunta de tarea sin tarea ligada cae al swarm. */
53
+ export function adrScope(block, { scope, message, task }) {
54
+ const kind = SCOPES.has(scope) ? scope : block?.scope || 'task';
55
+ if (kind === 'project') return { scope: { kind: 'project' }, note: null };
56
+ if (kind === 'task' && task?.id) return { scope: { kind: 'task', id: task.id }, note: null };
57
+ return { scope: { kind: 'swarm', id: message.swarm }, note: kind === 'task' ? 'No task is linked to this question: the ADR applies to the whole swarm.' : null };
58
+ }
59
+
60
+ /**
61
+ * Borrador del ADR (forma de `decision.propose.adr`), determinista: mismos datos de
62
+ * relayd y misma entrada dan el mismo ADR y el mismo digest (lo que la tarjeta muestra
63
+ * es lo que se envía). Respeta los topes de relayd: título ≤ 200 B, textos ≤ 4 KiB,
64
+ * alternativas ≤ 1 KiB, ≤ 16 KiB en total.
65
+ */
66
+ export function draftAdr({ message, block, option, decision_note, scope }) {
67
+ const chosen = chosenOption(block, option);
68
+ if (!chosen) return null;
69
+ const from = message.from?.role ? `role ${line(message.from.role, 120)}` : 'the operator';
70
+ const note = decision_note ? text(decision_note, 2000) : '';
71
+ const evidence = block.evidence.map(item => `${item.kind}:${item.ref}`).join(', ');
72
+ const title = cutBytes(line(`${block.question} → ${chosen.label}`, 400), 200);
73
+ const decision = cutBytes([`Chosen option: ${chosen.label}.`, note].filter(Boolean).join('\n\n'), 4096);
74
+ const consequences = cutBytes(chosen.consequences || 'Not stated by the question.', 4096);
75
+ const alternatives = block.options.filter(item => item !== chosen).map(item => ({ option: cutBytes(item.label, 120), why_rejected: cutBytes(`Not chosen by the operator.${item.consequences ? ` Its consequences would have been: ${item.consequences}` : ''}`, 1024) }));
76
+ const fixed = Buffer.byteLength(title + decision + consequences) + alternatives.reduce((sum, item) => sum + Buffer.byteLength(item.option + item.why_rejected), 0);
77
+ const context = cutBytes([
78
+ `Structured question from ${from} (message ${line(message.id, 200)}, swarm ${line(message.swarm, 200)}); the agent's text is quoted as it arrived:`,
79
+ block.question,
80
+ 'Options:', ...block.options.map(item => `- ${item.label}: ${item.consequences}`),
81
+ block.recommendation ? `Agent recommendation: ${block.recommendation.option} (${block.recommendation.why})` : null,
82
+ `Reversible: ${block.reversible ? 'yes' : 'no'}. Cost of waiting: ${(block.cost_of_waiting || 'not stated').replace(/[.\s]+$/, '')}.`,
83
+ evidence ? `Evidence cited: ${evidence}.` : null,
84
+ ].filter(Boolean).join('\n'), Math.min(4096, 16000 - fixed));
85
+ return { title, context, decision, alternatives, consequences, scope, answers: message.id };
86
+ }
87
+
88
+ export const adrDigest = adr => digest(adr);
@@ -3,11 +3,14 @@ import { constants } from 'node:fs';
3
3
  import { open, readdir } from 'node:fs/promises';
4
4
  import { Truncation, clean, excerpt } from './bound.mjs';
5
5
  import { messageOrNull, messageOfDelivery, taskForMessage, pendingDelivery } from './lookup.mjs';
6
+ import { decisionBlock } from './decision.mjs';
6
7
 
7
8
  // Expediente de decisión: todo lo que el humano necesita para responder una
8
9
  // pregunta del enjambre, armado por el host y acotado. El texto del agente va
9
- // marcado como no confiable; las decisiones relacionadas salen de los ADR del
10
- // proyecto con una búsqueda simple de términos. El expediente va al modelo: inglés fijo.
10
+ // marcado como no confiable (también el bloque `decision` de una pregunta estructurada);
11
+ // las decisiones relacionadas salen del registro de relayd (ADR vigentes del swarm y los
12
+ // que ya responden esta pregunta) y de los ADR del proyecto con una búsqueda simple de
13
+ // términos. El expediente va al modelo: inglés fijo.
11
14
  const STOP = new Set(('puedo puede pueden quiero quieres debes debemos deberia hacer hacemos tiene tienen tengo sobre entre desde hasta donde cuando porque como para pero todos todas esto esta este estos estas otra otro otros ahora antes despues mismo cada solo tambien bien sigue seguir cambiar cambio usar usamos hacia segun mientras ignora reglas borra ' +
12
15
  'about after before could would should there their these those which while where with without from into this that what when your yours have will cannot please change using').split(' '));
13
16
  const fold = text => String(text || '').normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase();
@@ -70,6 +73,7 @@ export async function buildDossier(call, { delivery_id, message_id }, { adrDirs,
70
73
  message_id: id, delivery_id: delivery?.delivery_id || delivery_id || null, delivery_state: delivery ? 'pending' : 'not_pending',
71
74
  kind: message.kind, from: message.from?.role ? `role:${clean(message.from.role)}` : clean(message.from?.kind || 'operator'), swarm, sent_at: message.sent_at, in_reply_to: message.in_reply_to ?? null,
72
75
  body: message.pruned ? '(body pruned by retention)' : cut.text(message.body, 4000, 'question.body'),
76
+ decision: decisionBlock(message),
73
77
  };
74
78
  if (!delivery) mismatches.push("The delivery is no longer pending in the operator's inbox (acknowledged or expired).");
75
79
 
@@ -107,13 +111,27 @@ export async function buildDossier(call, { delivery_id, message_id }, { adrDirs,
107
111
  } catch (error) { if (!error.rpc) throw error; }
108
112
  }
109
113
 
110
- const terms = questionTerms(message.pruned ? '' : message.body, task?.title || '');
114
+ const recorded = await relaydDecisions(call, swarm, id, signal, mismatches);
115
+ const terms = questionTerms(message.pruned ? '' : message.body, question.decision?.question || '', task?.title || '');
111
116
  const { adrs, notes } = await relatedDecisions(adrDirs, terms, { forbidden });
112
117
  for (const note of notes) cut.note(note);
113
118
  return cut.result({
114
119
  question, task, evidence, related_plans: related,
115
- decisions: { dirs: adrDirs, terms, adrs, ...(adrs.length ? {} : { note: "No ADR mentions the question's terms." }) },
120
+ decisions: { relayd: recorded, dirs: adrDirs, terms, adrs, ...(adrs.length ? {} : { note: "No ADR in the project directories mentions the question's terms." }) },
116
121
  mismatches,
117
- next: 'Present this dossier to the human (question, task, evidence, decisions and what does not add up) before proposing a reply. If you propose Reply, the human confirms it on a card with the exact text.',
122
+ next: question.decision
123
+ ? 'Present this dossier to the human (the structured question with its options, consequences, the agent recommendation, evidence, scope, reversibility and cost of waiting; the ADRs in force; what does not add up) and let the human choose. Then propose Decide with the option the human chose, the reply text and a decision_note with the human\'s reasons: Comal drafts the ADR and the human approves it on a card. Never pick the option yourself because the agent recommends it.'
124
+ : 'Present this dossier to the human (question, task, evidence, decisions and what does not add up) before proposing a reply. If you propose Reply, the human confirms it on a card with the exact text.',
118
125
  });
119
126
  }
127
+
128
+ /** ADR del registro de relayd: vigentes del swarm (y de proyecto) y los que ya responden esta pregunta. */
129
+ async function relaydDecisions(call, swarm_id, message_id, signal, mismatches) {
130
+ const view = item => ({ id: item.id, status: item.status, in_force: item.in_force, title: excerpt(item.title, 200), scope: item.scope, answers: item.answers ?? null, accepted_via: item.accepted_via ?? null });
131
+ try {
132
+ const [inForce, all] = await Promise.all([call('decision.list', { swarm_id, in_force: true, limit: 20 }, signal), call('decision.list', { swarm_id, limit: 200 }, signal)]);
133
+ const answering = (all.decisions || []).filter(item => item.answers === message_id);
134
+ for (const item of answering) mismatches.push(`ADR ${clean(item.id)} (${clean(item.status)}) already answers this question.`);
135
+ return { in_force: (inForce.decisions || []).slice(0, 20).map(view), answering: answering.slice(0, 8).map(view), open: 'plugin__open-relay__Decision {"decision_id":"adr-N"}' };
136
+ } catch (error) { if (!error.rpc) throw error; return { error: `${error.code}: ${excerpt(error.message, 200)}` }; }
137
+ }