agents-city 0.5.7 → 0.5.9

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "city",
11
11
  "source": "./plugin",
12
- "version": "0.5.7",
12
+ "version": "0.5.9",
13
13
  "description": "Operate one autonomous city seat, its domain, role, repo support agents, goal, recognised skills and explicit roads to other cities."
14
14
  }
15
15
  ]
package/README.es.md CHANGED
@@ -1828,6 +1828,18 @@ global del plugin porque varias ciudades pueden ejecutarse a la vez.
1828
1828
  sexta pregunta del asistente o con `agents-city seat --seat-yolo on|off`;
1829
1829
  `--no-yolo` sigue frenando la sesión entera.
1830
1830
 
1831
+ `ui.<agente>` elige qué se abre en una ventana: `tui` es el Claude Code de esa
1832
+ persona — sus plugins, su statusline, sus comandos — y `gateway` es el prompt de
1833
+ la ciudad con el runtime headless detrás. La silla usa `tui` por defecto y una
1834
+ casa `gateway`, y los dos defectos son los útiles: una silla es donde te sientas,
1835
+ y una casa es donde llega el trabajo sin que nadie se siente en ella, con la
1836
+ conversación entera apareciendo en el Hall.
1837
+
1838
+ El intercambio se dice, no se esconde. El TUI de Claude Code no tiene puerta de
1839
+ entrada, así que una ventana en `tui` recibe su trabajo como un pegado protegido
1840
+ en el pane, y el lanzador avisa de que la entrega nativa no está disponible ahí.
1841
+ Es el mismo trato que la silla lleva haciendo desde siempre.
1842
+
1831
1843
  `seat_reach` dice si la silla trabaja con sus propias manos. Está `closed`
1832
1844
  mientras no escribas `open`. Cerrado significa que un asiento tiene las
1833
1845
  herramientas de una silla y ninguna más: su carpeta de ciudad, las puertas de
package/README.md CHANGED
@@ -1808,6 +1808,18 @@ launches the chair itself without permission prompts — set at the wizard's
1808
1808
  sixth question or with `agents-city seat --seat-yolo on|off`; `--no-yolo`
1809
1809
  still brakes the whole session.
1810
1810
 
1811
+ `ui.<agent>` chooses what opens in a window: `tui` is that person's own Claude
1812
+ Code — their plugins, their statusline, their slash commands — and `gateway` is
1813
+ the city's own prompt with the runtime headless behind it. The chair defaults to
1814
+ `tui` and a house to `gateway`, and both defaults are the useful ones: a chair is
1815
+ where you sit, and a house is where work arrives without anybody sitting in it,
1816
+ with the whole conversation showing up in the Hall.
1817
+
1818
+ The trade is stated rather than hidden. Claude Code's TUI has no inbound door,
1819
+ so a `tui` window receives its work as a protected paste into the pane, and the
1820
+ launcher warns that native delivery is unavailable for it. That is the same
1821
+ bargain the chair has always made.
1822
+
1811
1823
  `seat_reach` says whether the chair works with its own hands. It is `closed`
1812
1824
  unless you write `open`. Closed means a seat holds a chair's tools and nothing
1813
1825
  else: its own city folder, this product's own doors (`agents-city …` — that is
@@ -117,6 +117,15 @@ def main():
117
117
  and 'agents-city committee respond' in body
118
118
  and 'Do not contact another repo agent directly' in body,
119
119
  body[-160:])
120
+ # A member read its brief and answered "let me start working on it",
121
+ # then ran thirteen shell commands. Investigating is what it was
122
+ # asked for; deciding by doing is not, and nothing in the brief had
123
+ # ever said so. Gathering an opinion has to leave the repo as it
124
+ # found it, or the committee is ratifying work already done.
125
+ afirma(f'· and {runtime} is told that gathering a position changes nothing',
126
+ 'Look, do not touch' in body
127
+ and 'leave the repo as it found it' in body,
128
+ body[-300:])
120
129
  esperado = (
121
130
  'operating role is seo'
122
131
  if actor == 'legacy-two'
@@ -220,6 +220,82 @@ def main():
220
220
  })
221
221
  afirma('· the chair resolves the request explicitly', denied.returncode == 0)
222
222
 
223
+ # ── the record, which had nobody checking it ────────────────────────
224
+ #
225
+ # Execution gets an independent verifier. The SUMMARY got nobody: a
226
+ # position quietly softened, a condition dropped, a stance reported as
227
+ # something it was not, and the member it belonged to had no valid basis
228
+ # to say so. Not new evidence, not a contradiction in the facts — a
229
+ # challenge to the chair's account of what they said.
230
+ mal_citado = valor(ejecuta(env, 'web', 'floor-request', thread, {
231
+ 'basis': 'misrepresented',
232
+ 'reason': 'the synthesis reports me as unconditional; my stance was conditional',
233
+ 'evidence': [],
234
+ }))
235
+ mal_id = mal_citado.get('myFloorRequests', [{}])[-1].get('id', '')
236
+ afirma('· a member can say the synthesis is not what it said', bool(mal_id))
237
+ peticion = next((r for r in mal_citado.get('myFloorRequests', [])
238
+ if r.get('id') == mal_id), {})
239
+ afirma(
240
+ "· and its own position travels with it, so the chair is shown what it summarised",
241
+ peticion.get('position', {}).get('recommendation')
242
+ == web_position['recommendation'],
243
+ json.dumps(peticion)[:300],
244
+ )
245
+ bloquea = ejecuta(env, 'seat', 'decide', thread, {
246
+ 'outcome': 'ship', 'rationale': 'gates pass', 'owner': 'alice',
247
+ 'executor': 'seat', 'verifier': 'ops',
248
+ 'verificationQuestion': 'does replay pass?',
249
+ 'selectedEvidence': ['run 482'], 'reopenIf': ['replay fails'],
250
+ })
251
+ afirma('· non-happy: and the chair cannot decide over an unanswered one',
252
+ bloquea.returncode != 0 and 'pending floor request' in bloquea.stderr)
253
+ ejecuta(env, 'seat', 'floor-deny', thread, {
254
+ 'requestId': mal_id, 'reason': 'the stance is quoted verbatim in the act',
255
+ })
256
+ # Resolved, so the pending guard is out of the way and what refuses the
257
+ # second one is the rule this basis carries: once.
258
+ otra_vez = ejecuta(env, 'web', 'floor-request', thread, {
259
+ 'basis': 'misrepresented', 'reason': 'still not right', 'evidence': [],
260
+ })
261
+ afirma('· non-happy: a correction that repeats is a filibuster with better manners',
262
+ otra_vez.returncode != 0
263
+ and 'already challenged how its position' in otra_vez.stderr,
264
+ otra_vez.stderr.strip())
265
+
266
+ # The one case where a member has nothing on the record: the chair
267
+ # synthesised without it, said why, and now it turns up. There is no
268
+ # summary of its position to be wrong, because it never gave one — and a
269
+ # basis that would let it claim otherwise would let anybody claim
270
+ # anything.
271
+ segundo = valor(ejecuta(env, 'seat', 'open', payload={
272
+ 'question': 'Do we cache the banner?',
273
+ 'desiredOutcome': 'A yes or no',
274
+ 'definitionOfDone': ['a decision'],
275
+ 'participants': ['api', 'ops'],
276
+ 'authority': 'recommend',
277
+ }))
278
+ hilo2 = segundo.get('id', '')
279
+ ejecuta(env, 'api', 'respond', hilo2, {
280
+ 'stance': 'support', 'recommendation': 'cache it',
281
+ 'evidence': ['cdn hit rate'], 'expectedImpact': 'fewer origin hits',
282
+ 'visibleWhen': 'next deploy', 'withdrawIf': 'the banner changes hourly',
283
+ })
284
+ sin_esperar = ejecuta(env, 'seat', 'synthesize', hilo2, {
285
+ 'summary': 'api says cache', 'agreements': ['cache it'],
286
+ 'conflicts': [], 'unknowns': [],
287
+ 'proceedWithout': 'ops has not answered in an hour and the window closes',
288
+ })
289
+ afirma('· a chair may proceed without a position, having said why',
290
+ sin_esperar.returncode == 0, sin_esperar.stderr.strip())
291
+ tarde = ejecuta(env, 'ops', 'floor-request', hilo2, {
292
+ 'basis': 'misrepresented', 'reason': 'that is not my view', 'evidence': [],
293
+ })
294
+ afirma('· non-happy: and one who said nothing has nothing to be misquoted about',
295
+ tarde.returncode != 0 and 'submitted no position' in tarde.stderr,
296
+ tarde.stderr.strip()[:200])
297
+ ejecuta(env, 'seat', 'cancel', hilo2, {'reason': 'fixture'})
298
+
223
299
  decision = {
224
300
  'outcome': 'ship after staging replay',
225
301
  'rationale': 'migration and rollback evidence agree',
@@ -313,9 +389,14 @@ def main():
313
389
  len(events) >= 16 and events[-1]['type'] == 'committee.close',
314
390
  f'{len(events)} events')
315
391
  history = valor(ejecuta(env, 'seat', 'history'))
392
+ # Indexed defensively: an earlier transition that stops working leaves
393
+ # these lists empty, and an IndexError here would replace every failure
394
+ # this suite had collected with a traceback about the last one.
395
+ reciente = (history.get('recent') or [{}])[0]
396
+ contribuyentes = (history.get('contributorCounts') or [{}])[0]
316
397
  afirma('· the chair gets concise cross-decision memory against capture',
317
- history.get('recent', [{}])[0].get('deliberation') == thread
318
- and history.get('contributorCounts', [{}])[0].get('decisions') == 2
398
+ reciente.get('deliberation') == thread
399
+ and contribuyentes.get('decisions') == 2
319
400
  and 'not proof of capture' in history.get('note', ''), str(history))
320
401
  denied_history = ejecuta(env, 'api', 'history')
321
402
  afirma('· decision influence history is chair-only',
@@ -474,9 +474,16 @@ def asiento_con_su_arnes():
474
474
  and "crossSessionInbound" not in fuente
475
475
  and "SendMessage,ListAgents" not in fuente, '')
476
476
 
477
- # A house is not a chair: it must still be reachable from the bus.
478
- afirma('· an agent house still goes through the gateway',
479
- 'gateway_line "$win" "$path" "$CLAUDE_REPO"' in fuente, '')
477
+ # A house is not a chair, and its DEFAULT must keep it reachable from the
478
+ # bus: work arrives without anybody sitting in that window. The choice is
479
+ # now offered — `ui.<house>: tui` opens the person's own Claude Code — so
480
+ # what this checks is the default, not the absence of a choice.
481
+ casa_claude = fuente[fuente.index('lanza_casa_claude() {'):fuente.index("# The chair's Claude")]
482
+ afirma('· a house defaults to the gateway, which is what makes work reach it',
483
+ 'ui_de "$win" gateway' in casa_claude
484
+ and 'gateway_line "$win" "$ruta" "$orden"' in casa_claude, casa_claude[:400])
485
+ afirma('· and a house that keeps its own CLI is given a way to be handed work',
486
+ '"$RUNTIME" fallback "$win"' in casa_claude, casa_claude[:400])
480
487
 
481
488
  # `ui.<window>`, lifted out and exercised.
482
489
  casa = tempfile.mkdtemp()
package/bin/test-seat.py CHANGED
@@ -1495,6 +1495,51 @@ def arranque_escalonado():
1495
1495
  and "--channels" not in asiento,
1496
1496
  asiento[:160],
1497
1497
  )
1498
+ # And the same choice, for a house. It is the point of the product — orchestrate
1499
+ # somebody's CLI, do not replace it — and until now `ui_de` was a general
1500
+ # function consulted for exactly one window.
1501
+ card.pon_campo(ficha, "ui.api", "tui")
1502
+ lineas = corre(CITY_SETTLE="0", CITY_STAGGER="0")
1503
+ api_tui = next((l for l in lineas if "CITY_BUS_ACTOR=api" in l), "")
1504
+ afirma(
1505
+ "· happy: `ui.api: tui` opens that house's own Claude Code, not a prompt in front of it",
1506
+ "city-runtime.sh gateway api" not in api_tui and "claude" in api_tui,
1507
+ api_tui[:300],
1508
+ )
1509
+ aviso = subprocess.run(
1510
+ ["bash", guion, "ana", "--claude", "--no-sync"],
1511
+ capture_output=True, text=True, cwd=casa,
1512
+ env=dict(os.environ, HOME=casa, AGENTS_CITY_DATA=datos, CITY_CODE_DIR=codigo,
1513
+ PATH=fbin + os.pathsep + os.environ["PATH"],
1514
+ CITY_SETTLE="0", CITY_STAGGER="0"),
1515
+ ).stderr
1516
+ afirma(
1517
+ "· and the city says out loud what that costs, rather than hiding it",
1518
+ "api uses the explicit terminal fallback" in aviso
1519
+ and "native delivery is unavailable" in aviso,
1520
+ aviso[-400:],
1521
+ )
1522
+ afirma(
1523
+ "· non-happy: while its cage and its bus identity are exactly as before",
1524
+ "CITY_BUS_ACTOR=api" in api_tui and "CITY_AGENT_ROLE=data-engineer" in api_tui,
1525
+ api_tui[:300],
1526
+ )
1527
+ card.pon_campo(ficha, "ui.api", "")
1528
+ api_pasarela = next(
1529
+ (l for l in corre(CITY_SETTLE="0", CITY_STAGGER="0") if "CITY_BUS_ACTOR=api" in l), "")
1530
+ afirma(
1531
+ "· non-happy: and clearing the key puts the gateway back, which is the default",
1532
+ "city-runtime.sh gateway api" in api_pasarela, api_pasarela[:300],
1533
+ )
1534
+ card.pon_campo(ficha, "ui.api", "ventana-preciosa")
1535
+ api_raro = next(
1536
+ (l for l in corre(CITY_SETTLE="0", CITY_STAGGER="0") if "CITY_BUS_ACTOR=api" in l), "")
1537
+ afirma(
1538
+ "· non-happy: a value that is neither tui nor gateway is not a third mode",
1539
+ "city-runtime.sh gateway api" in api_raro, api_raro[:300],
1540
+ )
1541
+ card.pon_campo(ficha, "ui.api", "")
1542
+
1498
1543
  # The old shape is still one card key away, for anybody who wants the bus
1499
1544
  # able to drive their chair.
1500
1545
  card.pon_campo(ficha, "ui.seat", "gateway")
@@ -1590,6 +1635,75 @@ def arranque_escalonado():
1590
1635
  str(lineas)[:400],
1591
1636
  )
1592
1637
 
1638
+ # A card that moved on under a window that is already open. This is the trap
1639
+ # the reconcile created: `ui.api: tui` on a running city applies to nothing,
1640
+ # and if nothing says so the owner sets it, reopens, sees the same gateway
1641
+ # and concludes the feature does not work.
1642
+ card.pon_campo(ficha, "ui.api", "tui")
1643
+ # The gateway's own pid marker: the only positive proof of what an already
1644
+ # open window is running.
1645
+ marcas = subprocess.run(
1646
+ [sys.executable, "-c",
1647
+ "import sys; sys.path.insert(0, sys.argv[1]); import runtime_processes as r;"
1648
+ " print(r.ruta(sys.argv[2]))",
1649
+ os.path.join(RAIZ, "plugin", "scripts"), datos],
1650
+ capture_output=True, text=True,
1651
+ # The launcher's own environment, or this resolves a different marker
1652
+ # directory from the one the launcher will look in.
1653
+ env={k: v for k, v in dict(os.environ, HOME=casa).items()
1654
+ if k != "AGENTS_CITY_HOME"},
1655
+ ).stdout.strip()
1656
+ os.makedirs(os.path.join(marcas, "gateways"), exist_ok=True)
1657
+ open(os.path.join(marcas, "gateways", "api.pid"), "w").write("4242\n")
1658
+ desfase = subprocess.run(
1659
+ ["bash", guion, "ana", "--claude", "--no-sync"],
1660
+ capture_output=True, text=True, cwd=casa,
1661
+ env=dict(os.environ, HOME=casa, AGENTS_CITY_DATA=datos, CITY_CODE_DIR=codigo,
1662
+ PATH=fbin + os.pathsep + os.environ["PATH"],
1663
+ CITY_SETTLE="0", CITY_STAGGER="0",
1664
+ FAKE_SESSION="1", FAKE_WINDOWS="seat api docs"),
1665
+ ).stderr
1666
+ afirma(
1667
+ "· happy: a card that moved on under an open window is reported, with what to run",
1668
+ "the card says tui" in desfase and "kill-window" in desfase and "api" in desfase,
1669
+ desfase[-500:],
1670
+ )
1671
+ afirma(
1672
+ "· non-happy: and nothing is closed on the owner's behalf",
1673
+ "kill-window" not in open(registro).read(),
1674
+ open(registro).read()[-300:],
1675
+ )
1676
+ card.pon_campo(ficha, "ui.api", "")
1677
+ sin_desfase = subprocess.run(
1678
+ ["bash", guion, "ana", "--claude", "--no-sync"],
1679
+ capture_output=True, text=True, cwd=casa,
1680
+ env=dict(os.environ, HOME=casa, AGENTS_CITY_DATA=datos, CITY_CODE_DIR=codigo,
1681
+ PATH=fbin + os.pathsep + os.environ["PATH"],
1682
+ CITY_SETTLE="0", CITY_STAGGER="0",
1683
+ FAKE_SESSION="1", FAKE_WINDOWS="seat api docs"),
1684
+ ).stderr
1685
+ afirma(
1686
+ "· non-happy: and a city whose windows agree with its card says nothing about them",
1687
+ "the card says" not in sin_desfase, sin_desfase[-300:],
1688
+ )
1689
+ # The absence of a marker is not proof of anything, and used to be read as
1690
+ # proof of a TUI.
1691
+ card.pon_campo(ficha, "ui.api", "tui")
1692
+ os.remove(os.path.join(marcas, "gateways", "api.pid"))
1693
+ sin_marca = subprocess.run(
1694
+ ["bash", guion, "ana", "--claude", "--no-sync"],
1695
+ capture_output=True, text=True, cwd=casa,
1696
+ env=dict(os.environ, HOME=casa, AGENTS_CITY_DATA=datos, CITY_CODE_DIR=codigo,
1697
+ PATH=fbin + os.pathsep + os.environ["PATH"],
1698
+ CITY_SETTLE="0", CITY_STAGGER="0",
1699
+ FAKE_SESSION="1", FAKE_WINDOWS="seat api docs"),
1700
+ ).stderr
1701
+ afirma(
1702
+ "· non-happy: with no marker it says nothing, rather than guessing what is running",
1703
+ "the card says" not in sin_marca, sin_marca[-300:],
1704
+ )
1705
+ card.pon_campo(ficha, "ui.api", "")
1706
+
1593
1707
  # A window whose agent left the card may be mid-task. Say so; do not close
1594
1708
  # it. The owner decides what to do with somebody's unfinished work.
1595
1709
  sobra = subprocess.run(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-city",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "Run autonomous agent cities: one chair seat, role-aware repo agents, repo-owned skills, and explicit roads.",
5
5
  "bin": {
6
6
  "agents-city": "bin/agents-city.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "city",
3
3
  "displayName": "Agents City",
4
- "version": "0.5.7",
4
+ "version": "0.5.9",
5
5
  "description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
6
6
  "author": {
7
7
  "name": "jlcases",
@@ -12,6 +12,7 @@ export function promptFor(envelope: BusEnvelope, operatingRole = 'blank'): strin
12
12
  RULES,
13
13
  role,
14
14
  `Your first position is isolated: do not seek or read another member's answer. Inspect your own repo and use any matching skill installed there. Abstention is valid.`,
15
+ `Look, do not touch. A position is what you have found and what you would do — reading, running read-only checks, and reporting. Nothing here asks you to change a file, land a branch or run a migration, and doing it now would decide the very thing the chair convened this committee to decide. The act of gathering an opinion must leave the repo as it found it; work follows a decision, and this is not one yet.`,
15
16
  `Brief:\n${pretty(envelope.payload.brief)}`,
16
17
  `Submit evidence, not a chat reply. Run:`,
17
18
  ` agents-city committee schema respond`,
@@ -25,8 +26,9 @@ export function promptFor(envelope: BusEnvelope, operatingRole = 'blank'): strin
25
26
  `${HEADER} The chair published the synthesis for ${thread}.`,
26
27
  RULES,
27
28
  pretty(envelope.payload.synthesis),
28
- `Stay silent unless you have new evidence, a contradiction, a material risk or a dependency. If so, request—not take—the floor:`,
29
+ `Stay silent unless you have new evidence, a contradiction, a material risk or a dependency. Wanting to add something is not one of those: your turn to contribute was your isolated position, and a floor taken for an opinion is how a committee becomes a chat room. If you do have one of the four, request—not take—the floor:`,
29
30
  ` agents-city committee floor-request ${thread} --input <request.json>`,
31
+ `There is a fifth basis and it is about this text rather than the world: \`misrepresented\`, for when the synthesis does not say what you said. Not "I disagree with the summary" — that it puts words in your mouth, drops your condition, or reports a stance you did not take. You get one, it does not spend your rebuttals even where the chair allowed none, and your own position is attached automatically so the chair is shown what it summarised rather than your memory of it.`,
30
32
  `Use: agents-city committee schema floor-request`,
31
33
  ].join('\n\n');
32
34
  }
@@ -3722,6 +3722,7 @@ function promptFor(envelope, operatingRole = "blank") {
3722
3722
  RULES,
3723
3723
  role,
3724
3724
  `Your first position is isolated: do not seek or read another member's answer. Inspect your own repo and use any matching skill installed there. Abstention is valid.`,
3725
+ `Look, do not touch. A position is what you have found and what you would do \u2014 reading, running read-only checks, and reporting. Nothing here asks you to change a file, land a branch or run a migration, and doing it now would decide the very thing the chair convened this committee to decide. The act of gathering an opinion must leave the repo as it found it; work follows a decision, and this is not one yet.`,
3725
3726
  `Brief:
3726
3727
  ${pretty(envelope.payload.brief)}`,
3727
3728
  `Submit evidence, not a chat reply. Run:`,
@@ -3736,8 +3737,9 @@ ${pretty(envelope.payload.brief)}`,
3736
3737
  `${HEADER} The chair published the synthesis for ${thread}.`,
3737
3738
  RULES,
3738
3739
  pretty(envelope.payload.synthesis),
3739
- `Stay silent unless you have new evidence, a contradiction, a material risk or a dependency. If so, request\u2014not take\u2014the floor:`,
3740
+ `Stay silent unless you have new evidence, a contradiction, a material risk or a dependency. Wanting to add something is not one of those: your turn to contribute was your isolated position, and a floor taken for an opinion is how a committee becomes a chat room. If you do have one of the four, request\u2014not take\u2014the floor:`,
3740
3741
  ` agents-city committee floor-request ${thread} --input <request.json>`,
3742
+ `There is a fifth basis and it is about this text rather than the world: \`misrepresented\`, for when the synthesis does not say what you said. Not "I disagree with the summary" \u2014 that it puts words in your mouth, drops your condition, or reports a stance you did not take. You get one, it does not spend your rebuttals even where the chair allowed none, and your own position is attached automatically so the chair is shown what it summarised rather than your memory of it.`,
3741
3743
  `Use: agents-city committee schema floor-request`
3742
3744
  ].join("\n\n");
3743
3745
  }
@@ -19816,6 +19816,7 @@ function promptFor(envelope, operatingRole = "blank") {
19816
19816
  RULES,
19817
19817
  role,
19818
19818
  `Your first position is isolated: do not seek or read another member's answer. Inspect your own repo and use any matching skill installed there. Abstention is valid.`,
19819
+ `Look, do not touch. A position is what you have found and what you would do \u2014 reading, running read-only checks, and reporting. Nothing here asks you to change a file, land a branch or run a migration, and doing it now would decide the very thing the chair convened this committee to decide. The act of gathering an opinion must leave the repo as it found it; work follows a decision, and this is not one yet.`,
19819
19820
  `Brief:
19820
19821
  ${pretty(envelope.payload.brief)}`,
19821
19822
  `Submit evidence, not a chat reply. Run:`,
@@ -19830,8 +19831,9 @@ ${pretty(envelope.payload.brief)}`,
19830
19831
  `${HEADER} The chair published the synthesis for ${thread}.`,
19831
19832
  RULES,
19832
19833
  pretty(envelope.payload.synthesis),
19833
- `Stay silent unless you have new evidence, a contradiction, a material risk or a dependency. If so, request\u2014not take\u2014the floor:`,
19834
+ `Stay silent unless you have new evidence, a contradiction, a material risk or a dependency. Wanting to add something is not one of those: your turn to contribute was your isolated position, and a floor taken for an opinion is how a committee becomes a chat room. If you do have one of the four, request\u2014not take\u2014the floor:`,
19834
19835
  ` agents-city committee floor-request ${thread} --input <request.json>`,
19836
+ `There is a fifth basis and it is about this text rather than the world: \`misrepresented\`, for when the synthesis does not say what you said. Not "I disagree with the summary" \u2014 that it puts words in your mouth, drops your condition, or reports a stance you did not take. You get one, it does not spend your rebuttals even where the chair allowed none, and your own position is attached automatically so the chair is shown what it summarised rather than your memory of it.`,
19835
19837
  `Use: agents-city committee schema floor-request`
19836
19838
  ].join("\n\n");
19837
19839
  }
@@ -4146,7 +4146,11 @@ function schema(verb) {
4146
4146
  unknowns: []
4147
4147
  },
4148
4148
  synthesize: { summary: "", agreements: [], conflicts: [], unknowns: [], proceedWithout: "" },
4149
- "floor-request": { basis: "new_evidence", reason: "", evidence: [""] },
4149
+ "floor-request": {
4150
+ basis: "new_evidence | contradiction | risk | dependency | misrepresented",
4151
+ reason: "what the committee does not yet know, or what the synthesis got wrong about you",
4152
+ evidence: [""]
4153
+ },
4150
4154
  "floor-grant": { requestId: "" },
4151
4155
  "floor-deny": { requestId: "", reason: "" },
4152
4156
  reply: { claim: "", evidence: [""], consequence: "" },
@@ -11,20 +11,42 @@ export function requestFloor(
11
11
  requireMember(state, actor, role);
12
12
  if (state.status !== 'deliberating')
13
13
  throw new Error('the floor opens only after the chair synthesis');
14
- const alreadyUsed = state.floor.replies.filter((reply) => reply.actor === actor).length;
14
+ const basis = oneOf(payload.basis, FLOOR_BASES, 'basis');
15
15
  const alreadyPending = state.floor.requests.some(
16
16
  (request) => request.actor === actor && ['pending', 'granted'].includes(request.status),
17
17
  );
18
18
  if (alreadyPending) throw new Error(`${actor} already has a pending floor request`);
19
- if (alreadyUsed >= state.brief.maxRebuttals) {
20
- throw new Error(`${actor} reached this deliberation's rebuttal limit`);
19
+ const mia = basis === 'misrepresented' ? state.positions[actor] : undefined;
20
+ if (basis === 'misrepresented') {
21
+ // Abstention is a valid position to hold and an empty one to misquote: with
22
+ // nothing on the record there is nothing the synthesis can have got wrong.
23
+ if (!mia) throw new Error(`${actor} submitted no position, so none can be misrepresented`);
24
+ // Once. Saying "that is not what I said" is a correction, and a correction
25
+ // that can be repeated is a filibuster with better manners.
26
+ if (state.floor.requests.some((r) => r.actor === actor && r.basis === 'misrepresented')) {
27
+ throw new Error(`${actor} already challenged how its position was represented`);
28
+ }
29
+ } else {
30
+ // The rebuttal budget governs DEBATE. A committee may legitimately forbid
31
+ // debate — `maxRebuttals: 0` is a real choice — and may not thereby forbid a
32
+ // member from saying the record is wrong about them, because the record is
33
+ // what the decision gets made from.
34
+ const alreadyUsed = state.floor.replies.filter((reply) => reply.actor === actor).length;
35
+ if (alreadyUsed >= state.brief.maxRebuttals) {
36
+ throw new Error(`${actor} reached this deliberation's rebuttal limit`);
37
+ }
21
38
  }
22
39
  const request: FloorRequest = {
23
40
  id: randomId('floor'),
24
41
  actor,
25
- basis: oneOf(payload.basis, FLOOR_BASES, 'basis'),
42
+ basis,
26
43
  reason: text(payload.reason, 'reason'),
27
- evidence: strings(payload.evidence, 'evidence', true),
44
+ // Every other basis has to bring something from the world. This one is
45
+ // answered by what the committee already holds — the position below — so
46
+ // demanding evidence on top would be asking a member to prove its own words
47
+ // with something other than its own words.
48
+ evidence: strings(payload.evidence, 'evidence', basis !== 'misrepresented'),
49
+ ...(mia ? { position: mia } : {}),
28
50
  status: 'pending',
29
51
  requestedAt: isoNow(),
30
52
  };
@@ -1,7 +1,28 @@
1
1
  import { ActorRole } from '../protocol.js';
2
2
 
3
3
  export const STANCES = ['support', 'oppose', 'conditional', 'abstain'] as const;
4
- export const FLOOR_BASES = ['new_evidence', 'contradiction', 'risk', 'dependency'] as const;
4
+ /** Why a member may interrupt a synthesis.
5
+ *
6
+ * The first four are about the WORLD: something is true that the chair did not
7
+ * have, said something the evidence contradicts, carries a risk, or depends on
8
+ * something. They are deliberately narrow — "I would like to add something" is
9
+ * how a committee becomes a chat room, and with the chat room comes the
10
+ * anchoring that isolated first positions exist to prevent.
11
+ *
12
+ * `misrepresented` is about the RECORD, and it is the one a member had no way
13
+ * to raise. A position summarised into something it did not say is neither new
14
+ * evidence nor a contradiction in the facts; it is the chair's account of what
15
+ * you said being wrong. Until this existed, the synthesis was the one place in
16
+ * the protocol where a single actor could bend the outcome and nothing could
17
+ * catch it: execution gets an independent verifier, the summary got nobody.
18
+ */
19
+ export const FLOOR_BASES = [
20
+ 'new_evidence',
21
+ 'contradiction',
22
+ 'risk',
23
+ 'dependency',
24
+ 'misrepresented',
25
+ ] as const;
5
26
  export const AUTHORITIES = ['recommend', 'decide', 'execute'] as const;
6
27
  export const VERIFY_RESULTS = ['pass', 'fail'] as const;
7
28
  export const TERMINAL_STATUSES = new Set(['closed', 'cancelled']);
@@ -40,6 +61,11 @@ export interface FloorRequest {
40
61
  basis: (typeof FLOOR_BASES)[number];
41
62
  reason: string;
42
63
  evidence: string[];
64
+ /** On `misrepresented`, the requester's own position, attached by the bus.
65
+ * The point of the basis is that the chair is shown the literal text it
66
+ * summarised, not a member's memory of it — quoting yourself from memory is
67
+ * the same class of evidence as the summary being challenged. */
68
+ position?: Position;
43
69
  status: 'pending' | 'granted' | 'used' | 'denied';
44
70
  requestedAt: string;
45
71
  decidedAt?: string;
@@ -82,7 +82,11 @@ function schema(verb: string): Record<string, unknown> {
82
82
  unknowns: [],
83
83
  },
84
84
  synthesize: { summary: '', agreements: [], conflicts: [], unknowns: [], proceedWithout: '' },
85
- 'floor-request': { basis: 'new_evidence', reason: '', evidence: [''] },
85
+ 'floor-request': {
86
+ basis: 'new_evidence | contradiction | risk | dependency | misrepresented',
87
+ reason: 'what the committee does not yet know, or what the synthesis got wrong about you',
88
+ evidence: [''],
89
+ },
86
90
  'floor-grant': { requestId: '' },
87
91
  'floor-deny': { requestId: '', reason: '' },
88
92
  reply: { claim: '', evidence: [''], consequence: '' },
@@ -3981,7 +3981,13 @@ function limpia(bruto) {
3981
3981
 
3982
3982
  // committee/types.ts
3983
3983
  var STANCES = ["support", "oppose", "conditional", "abstain"];
3984
- var FLOOR_BASES = ["new_evidence", "contradiction", "risk", "dependency"];
3984
+ var FLOOR_BASES = [
3985
+ "new_evidence",
3986
+ "contradiction",
3987
+ "risk",
3988
+ "dependency",
3989
+ "misrepresented"
3990
+ ];
3985
3991
  var AUTHORITIES = ["recommend", "decide", "execute"];
3986
3992
  var VERIFY_RESULTS = ["pass", "fail"];
3987
3993
  var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["closed", "cancelled"]);
@@ -4280,20 +4286,34 @@ function requestFloor(state, payload, actor, role) {
4280
4286
  requireMember(state, actor, role);
4281
4287
  if (state.status !== "deliberating")
4282
4288
  throw new Error("the floor opens only after the chair synthesis");
4283
- const alreadyUsed = state.floor.replies.filter((reply) => reply.actor === actor).length;
4289
+ const basis = oneOf(payload.basis, FLOOR_BASES, "basis");
4284
4290
  const alreadyPending = state.floor.requests.some(
4285
4291
  (request2) => request2.actor === actor && ["pending", "granted"].includes(request2.status)
4286
4292
  );
4287
4293
  if (alreadyPending) throw new Error(`${actor} already has a pending floor request`);
4288
- if (alreadyUsed >= state.brief.maxRebuttals) {
4289
- throw new Error(`${actor} reached this deliberation's rebuttal limit`);
4294
+ const mia = basis === "misrepresented" ? state.positions[actor] : void 0;
4295
+ if (basis === "misrepresented") {
4296
+ if (!mia) throw new Error(`${actor} submitted no position, so none can be misrepresented`);
4297
+ if (state.floor.requests.some((r) => r.actor === actor && r.basis === "misrepresented")) {
4298
+ throw new Error(`${actor} already challenged how its position was represented`);
4299
+ }
4300
+ } else {
4301
+ const alreadyUsed = state.floor.replies.filter((reply) => reply.actor === actor).length;
4302
+ if (alreadyUsed >= state.brief.maxRebuttals) {
4303
+ throw new Error(`${actor} reached this deliberation's rebuttal limit`);
4304
+ }
4290
4305
  }
4291
4306
  const request = {
4292
4307
  id: randomId("floor"),
4293
4308
  actor,
4294
- basis: oneOf(payload.basis, FLOOR_BASES, "basis"),
4309
+ basis,
4295
4310
  reason: text(payload.reason, "reason"),
4296
- evidence: strings(payload.evidence, "evidence", true),
4311
+ // Every other basis has to bring something from the world. This one is
4312
+ // answered by what the committee already holds — the position below — so
4313
+ // demanding evidence on top would be asking a member to prove its own words
4314
+ // with something other than its own words.
4315
+ evidence: strings(payload.evidence, "evidence", basis !== "misrepresented"),
4316
+ ...mia ? { position: mia } : {},
4297
4317
  status: "pending",
4298
4318
  requestedAt: isoNow()
4299
4319
  };
@@ -3723,6 +3723,7 @@ function promptFor(envelope, operatingRole = "blank") {
3723
3723
  RULES,
3724
3724
  role,
3725
3725
  `Your first position is isolated: do not seek or read another member's answer. Inspect your own repo and use any matching skill installed there. Abstention is valid.`,
3726
+ `Look, do not touch. A position is what you have found and what you would do \u2014 reading, running read-only checks, and reporting. Nothing here asks you to change a file, land a branch or run a migration, and doing it now would decide the very thing the chair convened this committee to decide. The act of gathering an opinion must leave the repo as it found it; work follows a decision, and this is not one yet.`,
3726
3727
  `Brief:
3727
3728
  ${pretty(envelope.payload.brief)}`,
3728
3729
  `Submit evidence, not a chat reply. Run:`,
@@ -3737,8 +3738,9 @@ ${pretty(envelope.payload.brief)}`,
3737
3738
  `${HEADER} The chair published the synthesis for ${thread}.`,
3738
3739
  RULES,
3739
3740
  pretty(envelope.payload.synthesis),
3740
- `Stay silent unless you have new evidence, a contradiction, a material risk or a dependency. If so, request\u2014not take\u2014the floor:`,
3741
+ `Stay silent unless you have new evidence, a contradiction, a material risk or a dependency. Wanting to add something is not one of those: your turn to contribute was your isolated position, and a floor taken for an opinion is how a committee becomes a chat room. If you do have one of the four, request\u2014not take\u2014the floor:`,
3741
3742
  ` agents-city committee floor-request ${thread} --input <request.json>`,
3743
+ `There is a fifth basis and it is about this text rather than the world: \`misrepresented\`, for when the synthesis does not say what you said. Not "I disagree with the summary" \u2014 that it puts words in your mouth, drops your condition, or reports a stance you did not take. You get one, it does not spend your rebuttals even where the chair allowed none, and your own position is attached automatically so the chair is shown what it summarised rather than your memory of it.`,
3742
3744
  `Use: agents-city committee schema floor-request`
3743
3745
  ].join("\n\n");
3744
3746
  }
@@ -526,6 +526,34 @@ entorno_de() { # $1 = actor ("seat" or a window name)
526
526
  printf '%s%s' "$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME " "$propio"
527
527
  }
528
528
 
529
+ # A house's Claude, opened the way its card asks for.
530
+ #
531
+ # `gateway` by default, and that default is what makes a city a city: work
532
+ # reaches the agent without anybody sitting in its terminal, and the whole
533
+ # conversation shows up in the Hall instead of in a pane nobody is watching.
534
+ #
535
+ # `ui.<house>: tui` opens the person's own Claude Code instead — their plugins,
536
+ # their statusline, their slash commands, their transcript. The cost is stated
537
+ # rather than hidden: delivery falls back to a protected paste into the pane,
538
+ # and `city-runtime.sh fallback` warns that native delivery is unavailable.
539
+ #
540
+ # That choice already existed for the chair, and `ui_de` was written as a
541
+ # general function consulted for exactly one window. Which is the tell: the
542
+ # chair — the one actor that receives text from OTHER cities — has been running
543
+ # pasted delivery all along, so refusing a house the same option while granting
544
+ # it there was backwards on risk, not careful about it.
545
+ lanza_casa_claude() { # $1 = window, $2 = cwd, $3 = the full claude command, $4 = wait
546
+ local win="$1" ruta="$2" orden="$3" espera="${4:-}" entorno
547
+ entorno="$(entorno_de "$win")$BROKER_ENV$JAULA$CAGE_RUNTIME_ENV"
548
+ if [ "$(ui_de "$win" gateway)" = tui ]; then
549
+ lanza "$SESSION:$win" "$win" "$ruta" "$espera$entorno${CLAUDE_AUTH_PREFIX}$orden"
550
+ "$RUNTIME" fallback "$win" "$SESSION:$win" claude
551
+ else
552
+ lanza "$SESSION:$win" "$win" "$ruta" \
553
+ "$espera$entorno${CLAUDE_AUTH_PREFIX}$(gateway_line "$win" "$ruta" "$orden")"
554
+ fi
555
+ }
556
+
529
557
  # The chair's Claude, opened the way the card asks for.
530
558
  #
531
559
  # Both spellings carry the SAME flags. That matters: `--settings` closes
@@ -651,8 +679,7 @@ for path in ${RUTAS[@]+"${RUTAS[@]}"}; do
651
679
  turno=$((turno + 1))
652
680
  if [ "$espera" = "0" ]; then espera=''; else espera="sleep $espera; "; fi
653
681
  CLAUDE_REPO="$(con_motor "$win" claude "$OTRO --name $SESSION-$win")$CLAUDE_TRATO$YOLO_FLAG"
654
- lanza "$SESSION:$win" "$win" "$path" \
655
- "$espera$(entorno_de "$win")$BROKER_ENV$JAULA$CAGE_RUNTIME_ENV${CLAUDE_AUTH_PREFIX}$(gateway_line "$win" "$path" "$CLAUDE_REPO")"
682
+ lanza_casa_claude "$win" "$path" "$CLAUDE_REPO" "$espera"
656
683
  elif [ "$KIND" = codex ] || [ "$KIND" = opencode ] || [ "$KIND" = kimi ]; then
657
684
  # Native servers have their own credentials and do not share Claude's OAuth race.
658
685
  # Codex stays outside the outer cage ON macOS ONLY: its node_repl MCP
@@ -684,8 +711,7 @@ for path in ${RUTAS[@]+"${RUTAS[@]}"}; do
684
711
  turno=$((turno + 1))
685
712
  if [ "$espera" = "0" ]; then espera=''; else espera="sleep $espera; "; fi
686
713
  CLAUDE_REPO="$(con_motor "$win" claude "claude --name $SESSION-$win")$CLAUDE_TRATO$YOLO_FLAG"
687
- lanza "$SESSION:$win" "$win" "$path" \
688
- "$espera$(entorno_de "$win")$BROKER_ENV${JAULA}$CAGE_RUNTIME_ENV${CLAUDE_AUTH_PREFIX}$(gateway_line "$win" "$path" "$CLAUDE_REPO")"
714
+ lanza_casa_claude "$win" "$path" "$CLAUDE_REPO" "$espera"
689
715
  fi
690
716
  fi
691
717
  done
@@ -731,6 +757,36 @@ if [ "$SESION_YA" -eq 1 ]; then
731
757
  else
732
758
  echo "Session '$SESSION' is already up, with every agent on the card — attaching." >&2
733
759
  fi
760
+ # A window that is already open is left alone — there may be work in it — and
761
+ # that is exactly why a card change to one of them has to be said out loud.
762
+ # `ui.dev: tui` on a running city applies to nothing and reports nothing, so
763
+ # the owner sets it, reopens, sees the same gateway and concludes the feature
764
+ # does not work. The gateway leaves a pid marker, so drift here is a fact and
765
+ # not a guess.
766
+ # Only where there is positive evidence. A gateway leaves a pid marker, so its
767
+ # presence proves what that window is running; its ABSENCE proves nothing —
768
+ # a session opened without --claude has no markers at all, and inferring "then
769
+ # it must be a TUI" reported drift on a city where nothing had drifted. So
770
+ # this speaks in one direction, and says nothing rather than something it
771
+ # cannot know.
772
+ desfasadas=()
773
+ MARCAS="$(python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import runtime_processes as r; print(r.ruta(sys.argv[2]))' "$(dirname "$0")" "$EQUIPO" 2>/dev/null || true)"
774
+ for w in $VENTANAS_YA; do
775
+ [ "$w" = seat ] && continue
776
+ [ "$(ui_de "$w" gateway)" = tui ] || continue
777
+ [ -n "$MARCAS" ] && [ -f "$MARCAS/gateways/$w.pid" ] \
778
+ && desfasadas+=("$w: the card says tui, and the open window is the city's gateway")
779
+ done
780
+ if [ ${#desfasadas[@]} -gt 0 ]; then
781
+ echo >&2
782
+ echo "These windows were left as they are, and their card has moved on:" >&2
783
+ printf ' %s\n' "${desfasadas[@]}" >&2
784
+ echo "Nothing was closed — there may be work in them. To apply:" >&2
785
+ for d in ${desfasadas[@]+"${desfasadas[@]}"}; do
786
+ echo " tmux kill-window -t $SESSION:${d%%:*} (then open the city again)" >&2
787
+ done
788
+ fi
789
+
734
790
  sobran=()
735
791
  for w in $VENTANAS_YA; do
736
792
  [ "$w" = seat ] && continue
@@ -103,16 +103,29 @@ Use a committee only when specialised repo evidence can change the decision:
103
103
  chair or another member before the barrier opens. Abstention is valid.
104
104
  3. Compare evidence and publish one synthesis. Do not count votes.
105
105
  4. After synthesis, members stay silent unless they can identify new evidence, a
106
- contradiction, a material risk or a dependency. They request the floor.
107
- 5. Grant or deny every request explicitly. A grant allows one scoped reply. That
106
+ contradiction, a material risk or a dependency. Wanting to add something is
107
+ not one of those the moment to contribute was the isolated position, and a
108
+ floor taken for an opinion is how a committee becomes a chat room. They
109
+ request the floor; they never take it.
110
+ 5. There is a fifth basis, `misrepresented`, and it is about the record rather
111
+ than the world: the synthesis does not say what that member said. Not
112
+ disagreement with the summary — words put in their mouth, a condition
113
+ dropped, a stance reported that they did not take. Each member gets one, it
114
+ does not spend the rebuttal budget even where the brief allowed none, and
115
+ the bus attaches their stored position so the chair is shown what it
116
+ summarised rather than a memory of it. A member the chair proceeded without
117
+ has nothing on the record and so cannot raise it. This exists because
118
+ execution gets an independent verifier and the summary had nobody: it was
119
+ the one place where a single actor could bend the outcome unchecked.
120
+ 6. Grant or deny every request explicitly. A grant allows one scoped reply. That
108
121
  intervention is then heard by all selected members, but nobody answers it
109
122
  directly: a material counterpoint requires a new request which the chair
110
123
  sequences. This creates deliberation without opening a free debate.
111
- 6. Record outcome, rationale, owner, executor, selected and rejected evidence,
124
+ 7. Record outcome, rationale, owner, executor, selected and rejected evidence,
112
125
  decisive contributors, dissent and observable conditions that reopen the
113
126
  decision. Review `agents-city committee history`; repeated influence is a
114
127
  prompt to inspect capture, not a verdict.
115
- 7. Assign verification to an agent other than the executor whenever one exists.
128
+ 8. Assign verification to an agent other than the executor whenever one exists.
116
129
  A failed verification must replan; only a pass may close the act.
117
130
 
118
131
  The vendor-neutral terminal door is: