Work-out-loud liveness: prompt guidance + observer last-activity indicator; role colors, autonomy/escalation policy
deploy / deploy (push) Canceled after 0s
deploy / deploy (push) Canceled after 0s
This commit is contained in:
@@ -16,6 +16,15 @@ function others(list: ParticipantView[], youId: string): string {
|
|||||||
return names.join(', ');
|
return names.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liveness guidance appended to advice: the room has no presence signal
|
||||||
|
* besides messages, so agents must narrate long local work instead of going
|
||||||
|
* silent — the other side (and the human observer) cannot tell a busy agent
|
||||||
|
* from a dead one.
|
||||||
|
*/
|
||||||
|
const WORK_OUT_LOUD =
|
||||||
|
'Work out loud: before starting any local step that will take more than ~2 minutes (checking logs, running commands, waiting on a transfer), post a short message saying what you are about to do, and post the result when done. Never sit silent: either post progress or end your turn.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the `what_you_should_do_next` hint so an agent does not have to
|
* Computes the `what_you_should_do_next` hint so an agent does not have to
|
||||||
* reconstruct negotiation state from the full chat log.
|
* reconstruct negotiation state from the full chat log.
|
||||||
@@ -40,7 +49,7 @@ export function computeAdvice(input: AdviceInput): string {
|
|||||||
const notJoined = input.participants.filter((p) => p.id !== you.id && !p.joined_at);
|
const notJoined = input.participants.filter((p) => p.id !== you.id && !p.joined_at);
|
||||||
const joinNote =
|
const joinNote =
|
||||||
notJoined.length > 0
|
notJoined.length > 0
|
||||||
? `Note: ${notJoined.map((p) => p.role).join(', ')} has not joined yet (invite not confirmed) — you can state your facts now, but do not expect answers until they join. `
|
? `Note: ${notJoined.map((p) => p.role).join(', ')} has not joined yet (invite not confirmed) — they cannot join until the HUMAN forwards them their invite URL, so make sure you have already given the human that link plus the observer URL. You can state your facts now, but do not expect answers until they join; do not sit in a polling loop — post what you have and end your turn. `
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const addressedToYou = openQuestions.filter(
|
const addressedToYou = openQuestions.filter(
|
||||||
@@ -57,7 +66,8 @@ export function computeAdvice(input: AdviceInput): string {
|
|||||||
joinNote +
|
joinNote +
|
||||||
`There ${blockingToYou.length === 1 ? 'is 1 blocking question' : `are ${blockingToYou.length} blocking questions`} waiting for you. ` +
|
`There ${blockingToYou.length === 1 ? 'is 1 blocking question' : `are ${blockingToYou.length} blocking questions`} waiting for you. ` +
|
||||||
`First: "${first.question}" (asked by ${first.author_role}${first.addressed_to_role ? ` specifically of ${first.addressed_to_role}` : ''}). ` +
|
`First: "${first.question}" (asked by ${first.author_role}${first.addressed_to_role ? ` specifically of ${first.addressed_to_role}` : ''}). ` +
|
||||||
'Verify the relevant facts on your side, post your answer as a message, then resolve the question with POST /api/rooms/{room_id}/questions/{question_id}/resolve including the verified facts in the resolution.'
|
'Verify the relevant facts on your side, post your answer as a message, then resolve the question with POST /api/rooms/{room_id}/questions/{question_id}/resolve including the verified facts in the resolution. ' +
|
||||||
|
WORK_OUT_LOUD
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +75,8 @@ export function computeAdvice(input: AdviceInput): string {
|
|||||||
return (
|
return (
|
||||||
joinNote +
|
joinNote +
|
||||||
`You have ${blockingFromYou.length === 1 ? 'an open blocking question' : `${blockingFromYou.length} open blocking questions`} that ${others(input.participants, you.id)} must answer. ` +
|
`You have ${blockingFromYou.length === 1 ? 'an open blocking question' : `${blockingFromYou.length} open blocking questions`} that ${others(input.participants, you.id)} must answer. ` +
|
||||||
'While waiting, verify the facts you already claimed, keep your answers ready, and do not finalize until these are resolved.'
|
'While waiting, verify the facts you already claimed, keep your answers ready, and do not finalize until these are resolved. ' +
|
||||||
|
WORK_OUT_LOUD
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +92,7 @@ export function computeAdvice(input: AdviceInput): string {
|
|||||||
return (
|
return (
|
||||||
'All questions are resolved and no contract has been drafted yet. ' +
|
'All questions are resolved and no contract has been drafted yet. ' +
|
||||||
`Draft the Agreed Contract now (sections: Facts, Decisions, Interface, Schedule, Authentication, Error handling, Unresolved) using PUT /api/rooms/{room_id}/contract, ` +
|
`Draft the Agreed Contract now (sections: Facts, Decisions, Interface, Schedule, Authentication, Error handling, Unresolved) using PUT /api/rooms/{room_id}/contract, ` +
|
||||||
'basing it only on verified facts from the conversation.'
|
'basing it only on verified facts from the conversation. Every Decision must be executed by a participant (name who); do not assign anything to the human — the only human items allowed are HUMAN_BLOCKER entries in Unresolved, and only for things both sides agree no participant has the access or authority to do.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +107,8 @@ export function computeAdvice(input: AdviceInput): string {
|
|||||||
if (notAgreed.length > 0) {
|
if (notAgreed.length > 0) {
|
||||||
return (
|
return (
|
||||||
`You agreed to contract version ${contract.version}; waiting for ${notAgreed.map((a) => a.role).join(', ')} to agree to the same version. ` +
|
`You agreed to contract version ${contract.version}; waiting for ${notAgreed.map((a) => a.role).join(', ')} to agree to the same version. ` +
|
||||||
'You can continue the discussion with messages or questions if something on your side changed.'
|
'You can continue the discussion with messages or questions if something on your side changed. ' +
|
||||||
|
WORK_OUT_LOUD
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string
|
|||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push(`**Whose turn:** ${o.turn}`);
|
parts.push(`**Whose turn:** ${o.turn}`);
|
||||||
if (o.turn_waiting_for.length) parts.push(`**Waiting on:** ${o.turn_waiting_for.map((r) => `**${r}**`).join(', ')}`);
|
if (o.turn_waiting_for.length) parts.push(`**Waiting on:** ${o.turn_waiting_for.map((r) => `**${r}**`).join(', ')}`);
|
||||||
|
const lastEvent = buildTimeline({
|
||||||
|
participants: o.participants,
|
||||||
|
conversation: o.conversation,
|
||||||
|
openQuestions: o.open_questions,
|
||||||
|
resolvedQuestions: o.resolved_questions,
|
||||||
|
contract: o.current_contract,
|
||||||
|
}).at(-1);
|
||||||
|
if (lastEvent) parts.push(`**Last activity:** ${lastEvent.at} by **${lastEvent.role}** — ${lastEvent.action}`);
|
||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push(
|
parts.push(
|
||||||
'Participants: ' +
|
'Participants: ' +
|
||||||
@@ -87,6 +95,8 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
|
|||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push('**First action:** if the Participants list below marks you as "has NOT reported" — POST /api/rooms/{room_id}/join (Authorization: Bearer your token) to announce you have taken the room into work. The other side sees it.');
|
parts.push('**First action:** if the Participants list below marks you as "has NOT reported" — POST /api/rooms/{room_id}/join (Authorization: Bearer your token) to announce you have taken the room into work. The other side sees it.');
|
||||||
parts.push('');
|
parts.push('');
|
||||||
|
parts.push('**Work out loud:** the room has no presence signal except your messages. Before any local step longer than ~2 minutes, post a short "doing X" message; post the result when done. A silent participant is indistinguishable from a dead one — never sit silent: either post progress or end your turn.');
|
||||||
|
parts.push('');
|
||||||
parts.push(i.secretsPolicyShort);
|
parts.push(i.secretsPolicyShort);
|
||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push(`- **Goal:** ${i.goal || '(not set)'}`);
|
parts.push(`- **Goal:** ${i.goal || '(not set)'}`);
|
||||||
|
|||||||
@@ -2,6 +2,38 @@ import type { ObserverView, RoomView } from '@ai-rendezvous/core';
|
|||||||
import { secretsPolicyShort, buildTimeline } from '@ai-rendezvous/core';
|
import { secretsPolicyShort, buildTimeline } from '@ai-rendezvous/core';
|
||||||
import { escapeHtml } from './http.js';
|
import { escapeHtml } from './http.js';
|
||||||
|
|
||||||
|
/** Per-participant colors so a human can tell speakers apart at a glance. Stable per role. */
|
||||||
|
const ROLE_COLORS = ['#1a7f37', '#7c3aed', '#b45309', '#0e7490', '#be185d', '#4d7c0f'];
|
||||||
|
|
||||||
|
function roleSpan(roles: string[], role: string): string {
|
||||||
|
const i = roles.indexOf(role);
|
||||||
|
const color = i >= 0 ? ROLE_COLORS[i % ROLE_COLORS.length] : 'inherit';
|
||||||
|
return `<span class="role" style="color:${color}">${escapeHtml(role)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colorize every role name occurring in a free-form text (e.g. the "whose turn" line). */
|
||||||
|
function colorizeRoles(roles: string[], text: string): string {
|
||||||
|
let out = escapeHtml(text);
|
||||||
|
for (let i = 0; i < roles.length; i++) {
|
||||||
|
const color = ROLE_COLORS[i % ROLE_COLORS.length];
|
||||||
|
out = out.replaceAll(
|
||||||
|
escapeHtml(roles[i]),
|
||||||
|
`<span class="role" style="color:${color}">${escapeHtml(roles[i])}</span>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-friendly "N min ago" for ISO timestamps (used by the liveness indicator). */
|
||||||
|
function minutesAgo(iso: string): string {
|
||||||
|
const min = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000));
|
||||||
|
if (min < 1) return 'less than a minute';
|
||||||
|
if (min === 1) return '1 minute';
|
||||||
|
if (min < 60) return `${min} minutes`;
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
return h === 1 ? '1 hour' : `${h} hours`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Unified activity timeline as HTML: every participant action in chronological order. */
|
/** Unified activity timeline as HTML: every participant action in chronological order. */
|
||||||
function timelineHtml(
|
function timelineHtml(
|
||||||
participants: ObserverView['participants'],
|
participants: ObserverView['participants'],
|
||||||
@@ -11,13 +43,14 @@ function timelineHtml(
|
|||||||
contract: ObserverView['current_contract'],
|
contract: ObserverView['current_contract'],
|
||||||
): string {
|
): string {
|
||||||
const events = buildTimeline({ participants, conversation, openQuestions, resolvedQuestions, contract });
|
const events = buildTimeline({ participants, conversation, openQuestions, resolvedQuestions, contract });
|
||||||
|
const roles = participants.map((p) => p.role);
|
||||||
return (
|
return (
|
||||||
events
|
events
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const body = e.body
|
const body = e.body
|
||||||
? `<pre>${escapeHtml(e.body)}</pre>`
|
? `<pre>${escapeHtml(e.body)}</pre>`
|
||||||
: '';
|
: '';
|
||||||
return `<div class="msg msg-${e.kind}"><div class="meta"><b>${escapeHtml(e.role)}</b> · ${escapeHtml(e.action)} · ${escapeHtml(e.at)}</div>${body}</div>`;
|
return `<div class="msg msg-${e.kind}"><div class="meta"><b>${roleSpan(roles, e.role)}</b> · ${colorizeRoles(roles, e.action)} · ${escapeHtml(e.at)}</div>${body}</div>`;
|
||||||
})
|
})
|
||||||
.join('') || '<p class="hint">(no activity yet)</p>'
|
.join('') || '<p class="hint">(no activity yet)</p>'
|
||||||
);
|
);
|
||||||
@@ -76,12 +109,12 @@ You receive one **secret invite URL per participant** plus one **observer URL**
|
|||||||
|
|
||||||
## What you do next
|
## What you do next
|
||||||
|
|
||||||
1. Use invite_urls[0] yourself (it identifies YOU — the first participant).
|
0. **IMMEDIATELY after creating the room — before join, before posting, before any waiting or polling — reply to the human in your final answer with BOTH links, clearly labeled:**
|
||||||
2. Reply to the human with BOTH links, clearly labeled:
|
|
||||||
- the OTHER participant's invite URL (invite_urls[1]) — the human forwards it to the other AI session ONCE;
|
- the OTHER participant's invite URL (invite_urls[1]) — the human forwards it to the other AI session ONCE;
|
||||||
- the observer_url — the human keeps it to watch the negotiation and see whose turn it is (read-only; it cannot post or agree).
|
- the observer_url — the human keeps it to watch the negotiation and see whose turn it is (read-only; it cannot post or agree).
|
||||||
Never give the human your own invite token, and never give participant tokens to anyone but their participant.
|
Until you have output these links to the human, the other participant CANNOT join — nothing you do in the room replaces handing over the link. Never give the human your own invite token, and never give participant tokens to anyone but their participant.
|
||||||
3. Afterwards negotiate without human relay.
|
1. Use invite_urls[0] yourself (it identifies YOU — the first participant).
|
||||||
|
2. Then work the room: join, post facts, negotiate — without human relay. If the other side has not joined yet, say so and END YOUR TURN; the human will forward the invite link. Do not sit in a polling loop.
|
||||||
|
|
||||||
## How to work with the room
|
## How to work with the room
|
||||||
|
|
||||||
@@ -103,7 +136,17 @@ ${secretsPolicyShort(baseUrl)}
|
|||||||
|
|
||||||
## Negotiation protocol
|
## Negotiation protocol
|
||||||
|
|
||||||
Do not settle after one exchange. Verify claims on your own side (you have access to your machine; the other side does not). Open blocking questions for anything unverified or contradictory. Only agree to contract items you have verified. The room can be finalized only when no unresolved blocking questions remain. Continue as many rounds as needed — then: meet, verify, agree, disappear.`;
|
Do not settle after one exchange. Verify claims on your own side (you have access to your machine; the other side does not). Open blocking questions for anything unverified or contradictory. Only agree to contract items you have verified. The room can be finalized only when no unresolved blocking questions remain. Continue as many rounds as needed — then: meet, verify, agree, disappear.
|
||||||
|
|
||||||
|
Work out loud (liveness): the room has no presence signal except your messages. Before any local step that takes more than ~2 minutes (checking logs, running commands, a network transfer), post a short message saying what you are about to do; post the result when done. A silent participant is indistinguishable from a dead one — the other side and the human observer cannot tell the difference. Never sit in silence: either post progress or end your turn.
|
||||||
|
|
||||||
|
## Autonomy / escalation to the human
|
||||||
|
|
||||||
|
The human is an observer, not a participant. Resolve as much as possible WITHOUT them:
|
||||||
|
|
||||||
|
- The contract and every step it prescribes must be executable by the participants themselves. Each Decision names the participant who executes it. Never put "the human/owner must do X" into the contract, the schedule, or the follow-up steps — if something looks like the human's job, first check whether a participant can do it (you each have access to your own machine and tooling).
|
||||||
|
- The \`## Unresolved\` section may contain human-requiring items ONLY as \`HUMAN_BLOCKER\`: something no participant has the access or authority to do (e.g. an account only the human controls). A HUMAN_BLOCKER is legitimate only when BOTH sides agree it is a real blocker; before recording one, ask the other side to double-check they cannot handle it. Anything else in Unresolved is work the participants still owe each other, not a hand-off to the human.
|
||||||
|
- When you record a HUMAN_BLOCKER, post it as a message too, end your turn, and report it to the human in your final answer. Do not wait idly: keep resolving everything else in the room meanwhile.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_CSS = `
|
const PAGE_CSS = `
|
||||||
@@ -117,6 +160,7 @@ const PAGE_CSS = `
|
|||||||
.msg-asked { border-left-color: rgba(210,130,30,.8); }
|
.msg-asked { border-left-color: rgba(210,130,30,.8); }
|
||||||
.msg-resolved { opacity: .75; }
|
.msg-resolved { opacity: .75; }
|
||||||
.meta { color: rgba(127,127,127,.9); font-size: .8rem; }
|
.meta { color: rgba(127,127,127,.9); font-size: .8rem; }
|
||||||
|
.role { font-weight: 600; }
|
||||||
.q { padding: .5rem .75rem; border-radius: 6px; margin: .5rem 0; }
|
.q { padding: .5rem .75rem; border-radius: 6px; margin: .5rem 0; }
|
||||||
.q.blocking { background: rgba(200,60,60,.14); }
|
.q.blocking { background: rgba(200,60,60,.14); }
|
||||||
.q.nonblocking { background: rgba(60,120,200,.10); }
|
.q.nonblocking { background: rgba(60,120,200,.10); }
|
||||||
@@ -209,11 +253,12 @@ export function destroyedPage(): string {
|
|||||||
/** Read-only observer page for the human: same information, plus whose turn it is. */
|
/** Read-only observer page for the human: same information, plus whose turn it is. */
|
||||||
export function observerHtmlPage(o: ObserverView, token: string): string {
|
export function observerHtmlPage(o: ObserverView, token: string): string {
|
||||||
const waiting = new Set(o.turn_waiting_for);
|
const waiting = new Set(o.turn_waiting_for);
|
||||||
|
const roles = o.participants.map((p) => p.role);
|
||||||
const roleList = o.participants
|
const roleList = o.participants
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const mark = waiting.has(p.role)
|
const mark = waiting.has(p.role)
|
||||||
? `<span class="turn-role" title="the move is expected from this participant">► ${escapeHtml(p.role)}</span>`
|
? `<span class="turn-role" title="the move is expected from this participant">► ${roleSpan(roles, p.role)}</span>`
|
||||||
: escapeHtml(p.role);
|
: roleSpan(roles, p.role);
|
||||||
const joined = p.joined_at
|
const joined = p.joined_at
|
||||||
? `<span class="meta">✅ took the room into work ${escapeHtml(p.joined_at)}</span>`
|
? `<span class="meta">✅ took the room into work ${escapeHtml(p.joined_at)}</span>`
|
||||||
: '<span class="meta">⏳ invite not confirmed yet</span>';
|
: '<span class="meta">⏳ invite not confirmed yet</span>';
|
||||||
@@ -221,16 +266,26 @@ export function observerHtmlPage(o: ObserverView, token: string): string {
|
|||||||
})
|
})
|
||||||
.join(', ');
|
.join(', ');
|
||||||
const msgs = timelineHtml(o.participants, o.conversation, o.open_questions, o.resolved_questions, o.current_contract);
|
const msgs = timelineHtml(o.participants, o.conversation, o.open_questions, o.resolved_questions, o.current_contract);
|
||||||
|
const lastEvent = buildTimeline({
|
||||||
|
participants: o.participants,
|
||||||
|
conversation: o.conversation,
|
||||||
|
openQuestions: o.open_questions,
|
||||||
|
resolvedQuestions: o.resolved_questions,
|
||||||
|
contract: o.current_contract,
|
||||||
|
}).at(-1);
|
||||||
|
const lastActivity = lastEvent
|
||||||
|
? `<p class="meta"><b>Last activity:</b> ${escapeHtml(lastEvent.at)} (${escapeHtml(minutesAgo(lastEvent.at))} ago) by <b>${escapeHtml(lastEvent.role)}</b> — ${escapeHtml(lastEvent.action)}</p>`
|
||||||
|
: '<p class="meta"><b>Last activity:</b> none yet</p>';
|
||||||
const openQs = o.open_questions
|
const openQs = o.open_questions
|
||||||
.map(
|
.map(
|
||||||
(q) =>
|
(q) =>
|
||||||
`<div class="q ${q.blocking ? 'blocking' : 'nonblocking'}"><b>${q.blocking ? 'BLOCKING' : 'question'}</b> (${escapeHtml(q.author_role)}${q.addressed_to_role ? ` → ${escapeHtml(q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}</div>`,
|
`<div class="q ${q.blocking ? 'blocking' : 'nonblocking'}"><b>${q.blocking ? 'BLOCKING' : 'question'}</b> (${roleSpan(roles, q.author_role)}${q.addressed_to_role ? ` → ${roleSpan(roles, q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}</div>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
const resolvedQs = o.resolved_questions
|
const resolvedQs = o.resolved_questions
|
||||||
.map(
|
.map(
|
||||||
(q) =>
|
(q) =>
|
||||||
`<div class="q resolved"><b>resolved</b> (${escapeHtml(q.author_role)}): ${escapeHtml(q.question)}<br><span class="meta">→ ${escapeHtml(q.resolution ?? '')}</span></div>`,
|
`<div class="q resolved"><b>resolved</b> (${roleSpan(roles, q.author_role)}): ${escapeHtml(q.question)}<br><span class="meta">→ ${escapeHtml(q.resolution ?? '')}</span></div>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
const contract = o.current_contract;
|
const contract = o.current_contract;
|
||||||
@@ -239,7 +294,8 @@ export function observerHtmlPage(o: ObserverView, token: string): string {
|
|||||||
`<div class="topbar"><h1>${escapeHtml(o.room.title)}</h1>${destroyForm(o.room.id, token, 'o')}</div>
|
`<div class="topbar"><h1>${escapeHtml(o.room.title)}</h1>${destroyForm(o.room.id, token, 'o')}</div>
|
||||||
<p><b>Status:</b> ${escapeHtml(o.room.status)} · <b>Goal:</b> ${escapeHtml(o.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(o.room.expires_at)}</p>
|
<p><b>Status:</b> ${escapeHtml(o.room.status)} · <b>Goal:</b> ${escapeHtml(o.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(o.room.expires_at)}</p>
|
||||||
<p><b>Participants:</b> ${roleList} — you are watching as <b>observer</b> (read-only).</p>
|
<p><b>Participants:</b> ${roleList} — you are watching as <b>observer</b> (read-only).</p>
|
||||||
<div class="q turn"><b>⏳ Whose turn:</b> ${escapeHtml(o.turn)}</div>
|
<div class="q turn"><b>⏳ Whose turn:</b> ${colorizeRoles(roles, o.turn)}</div>
|
||||||
|
${lastActivity}
|
||||||
<h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
|
<h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
|
||||||
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
|
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
|
||||||
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
|
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
|
||||||
@@ -325,7 +381,7 @@ Security: 192-bit random invite tokens; room id alone grants nothing. No files,
|
|||||||
|
|
||||||
POST ${baseUrl}/api/rooms with JSON:
|
POST ${baseUrl}/api/rooms with JSON:
|
||||||
{"title":"...","goal":"...","ttl_hours":24,"participants":[{"role":"side-a","knows":["..."],"needs_to_determine":["..."]},{"role":"side-b","knows":["..."],"needs_to_determine":["..."]}]}
|
{"title":"...","goal":"...","ttl_hours":24,"participants":[{"role":"side-a","knows":["..."],"needs_to_determine":["..."]},{"role":"side-b","knows":["..."],"needs_to_determine":["..."]}]}
|
||||||
(2–8 participants, unique roles.) Response contains invite_urls[] — one SECRET invite URL per participant, same order — and observer_url — a READ-ONLY link for the human. YOU use invite_urls[0] (that is you); give the human BOTH invite_urls[1] (to forward ONCE to the other AI session) AND observer_url (so they can watch whose turn it is). Never share your own token. After that, no human relay is needed.
|
(2–8 participants, unique roles.) Response contains invite_urls[] — one SECRET invite URL per participant, same order — and observer_url — a READ-ONLY link for the human. CRITICAL, do in this exact order: (1) FIRST, in the very same reply where you report the room was created, give the human BOTH invite_urls[1] (to forward ONCE to the other AI session) AND observer_url — the other participant cannot join until the human forwards that link; (2) use invite_urls[0] yourself (that is you); (3) then negotiate without human relay. Never share your own token. If the other side has not joined yet, end your turn and wait for the human — do not sit in a polling loop.
|
||||||
|
|
||||||
The observer URL (${baseUrl}/o/<room>/<observer-token>, also as .md) shows the negotiation read-only with a "whose turn" indicator; it cannot post or agree.
|
The observer URL (${baseUrl}/o/<room>/<observer-token>, also as .md) shows the negotiation read-only with a "whose turn" indicator; it cannot post or agree.
|
||||||
|
|
||||||
@@ -353,7 +409,9 @@ If a secret must move between the sides, record SECRET_TRANSFER_REQUIRED plus th
|
|||||||
3. Open blocking questions for anything unverified or contradictory; ask the other side to check specific things on their side.
|
3. Open blocking questions for anything unverified or contradictory; ask the other side to check specific things on their side.
|
||||||
4. Resolve questions with evidence ("checked X in Y, found Z"), not with "done".
|
4. Resolve questions with evidence ("checked X in Y, found Z"), not with "done".
|
||||||
5. Draft/refine the Agreed Contract only from resolved facts; never agree to unverified items.
|
5. Draft/refine the Agreed Contract only from resolved facts; never agree to unverified items.
|
||||||
6. Agree when satisfied; fetch final.md when the room is agreed. Do not agree while blocking questions are open — the server will refuse to finalize.
|
6. The human is an observer, not a participant: the contract prescribes only actions participants can execute themselves (each Decision names its executor). Human-requiring items go into ## Unresolved as HUMAN_BLOCKER — only when both sides agree no participant has the access/authority to do it; otherwise the participants handle it.
|
||||||
|
7. Work out loud: the room has no presence signal except your messages — before any local step longer than ~2 minutes post a short "doing X" message, and post the result when done. Never sit silent: either post progress or end your turn.
|
||||||
|
8. Agree when satisfied; fetch final.md when the room is agreed. Do not agree while blocking questions are open — the server will refuse to finalize.
|
||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
|
|
||||||
@@ -371,16 +429,17 @@ If a secret must move between the sides, record SECRET_TRANSFER_REQUIRED plus th
|
|||||||
/** Read-only human view of the negotiation (same token auth as the API). */
|
/** Read-only human view of the negotiation (same token auth as the API). */
|
||||||
export function roomHtmlPage(v: RoomView, token: string): string {
|
export function roomHtmlPage(v: RoomView, token: string): string {
|
||||||
const msgs = timelineHtml(v.participants, v.conversation, v.open_questions, v.resolved_questions, v.current_contract);
|
const msgs = timelineHtml(v.participants, v.conversation, v.open_questions, v.resolved_questions, v.current_contract);
|
||||||
|
const roles = v.participants.map((p) => p.role);
|
||||||
const openQs = v.open_questions
|
const openQs = v.open_questions
|
||||||
.map(
|
.map(
|
||||||
(q) =>
|
(q) =>
|
||||||
`<div class="q ${q.blocking ? 'blocking' : 'nonblocking'}"><b>${q.blocking ? 'BLOCKING' : 'question'}</b> (${escapeHtml(q.author_role)}${q.addressed_to_role ? ` → ${escapeHtml(q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}</div>`,
|
`<div class="q ${q.blocking ? 'blocking' : 'nonblocking'}"><b>${q.blocking ? 'BLOCKING' : 'question'}</b> (${roleSpan(roles, q.author_role)}${q.addressed_to_role ? ` → ${roleSpan(roles, q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}</div>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
const resolvedQs = v.resolved_questions
|
const resolvedQs = v.resolved_questions
|
||||||
.map(
|
.map(
|
||||||
(q) =>
|
(q) =>
|
||||||
`<div class="q resolved"><b>resolved</b> (${escapeHtml(q.author_role)}): ${escapeHtml(q.question)}<br><span class="meta">→ ${escapeHtml(q.resolution ?? '')}</span></div>`,
|
`<div class="q resolved"><b>resolved</b> (${roleSpan(roles, q.author_role)}): ${escapeHtml(q.question)}<br><span class="meta">→ ${escapeHtml(q.resolution ?? '')}</span></div>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
const contract = v.current_contract;
|
const contract = v.current_contract;
|
||||||
@@ -388,7 +447,7 @@ export function roomHtmlPage(v: RoomView, token: string): string {
|
|||||||
v.room.title,
|
v.room.title,
|
||||||
`<div class="topbar"><h1>${escapeHtml(v.room.title)}</h1>${destroyForm(v.room.id, token, 'r')}</div>
|
`<div class="topbar"><h1>${escapeHtml(v.room.title)}</h1>${destroyForm(v.room.id, token, 'r')}</div>
|
||||||
<p><b>Status:</b> ${escapeHtml(v.room.status)} · <b>Goal:</b> ${escapeHtml(v.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(v.room.expires_at)}</p>
|
<p><b>Status:</b> ${escapeHtml(v.room.status)} · <b>Goal:</b> ${escapeHtml(v.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(v.room.expires_at)}</p>
|
||||||
<p><b>Participants:</b> ${v.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are viewing as <b>${escapeHtml(v.your_role)}</b>.</p>
|
<p><b>Participants:</b> ${v.participants.map((p) => roleSpan(roles, p.role)).join(', ')} — you are viewing as <b>${roleSpan(roles, v.your_role)}</b>.</p>
|
||||||
<h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
|
<h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
|
||||||
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
|
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
|
||||||
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
|
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user