@appchy/jarvis 0.1.122 → 0.1.124

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.
@@ -868,6 +868,27 @@ def _what_this_repo_has_decided(cfg: dict, repo) -> list:
868
868
  out.append("what the releases are for, so a plan does not win this week "
869
869
  "and cost the next one:\n " + "\n ".join(cuts))
870
870
  return out
871
+ def _who_else_is_here(repo) -> str:
872
+ """The peers line, or `""` — composed by `peers`, which is the one place that
873
+ knows what a peer is, and assembled here only because this is where the board's
874
+ own history is already being read.
875
+
876
+ Everything it needs is best-effort: a repo that is not a git checkout has no
877
+ history and no branch, and the line degrades to who is live rather than failing.
878
+ A session start that cannot be told about its peers must still be told everything
879
+ else.
880
+ """
881
+ if repo is None:
882
+ return ""
883
+ from . import events, git, peers
884
+ root = Path(repo) / "work"
885
+ try:
886
+ rows = events.read(root) if root.is_dir() else []
887
+ except (OSError, ValueError):
888
+ rows = []
889
+ return peers.briefing(repo, rows, git.branch(repo))
890
+
891
+
871
892
  #: What the block is, said once, above every line of it. It names no repo and makes
872
893
  #: no claim a config could contradict — which is what lets it be the same sentence
873
894
  #: wherever the block is served.
@@ -893,6 +914,15 @@ def session_pointers(cfg: dict, repo=None) -> list:
893
914
  out = [f"the board: tasks live under `work/` — `{cli()} list` shows what is "
894
915
  f"moving, `{cli()} where <id>` resolves any rule id to its home."]
895
916
 
917
+ # Said FIRST of everything derived, and only when there is somebody: a session
918
+ # that picks up overlapping work finds out by overwriting it, and the moment to
919
+ # prevent that is before it has read anything else. Silence when alone is the
920
+ # design — this reaches every session in every repo running the harness, forever,
921
+ # so it has to cost nothing on the ordinary day.
922
+ peers_here = _who_else_is_here(repo)
923
+ if peers_here:
924
+ out.append(peers_here)
925
+
896
926
  # The guidance stack, ANNOUNCED. What ships is an engine plus somebody's
897
927
  # methodology, and a session that cannot tell which is which will cite the
898
928
  # preset's reasoning as though the tool enforced it. Saying it also makes an
@@ -199,6 +199,15 @@ def machine() -> str:
199
199
  return (os.environ.get("WORK_MACHINE") or socket.gethostname() or "").strip()
200
200
 
201
201
 
202
+ def branch(repo) -> str:
203
+ """Which branch this checkout is on, or `""` — a detached HEAD and a directory
204
+ that is not a repo at all both answer the same way, because neither has a branch
205
+ to name and a caller only ever wants the name."""
206
+ code, out, _ = _git(repo, "rev-parse", "--abbrev-ref", "HEAD", timeout=5)
207
+ named = out.strip()
208
+ return "" if code != 0 or named == "HEAD" else named
209
+
210
+
202
211
  def _git(repo, *argv, timeout=30):
203
212
  """Run one git command. Returns (code, stdout, stderr); a git that is missing or
204
213
  hangs is a non-zero code with a reason, never an exception — a board write must
@@ -167,15 +167,25 @@ def session_name(task) -> str:
167
167
  one Claude Code derives a name from the FOLDER — identical for every session
168
168
  in a repo, so several runs are told apart only by an id nobody reads.
169
169
 
170
+ **It is the item's NAME, not its title** (founder, 2026-09-12). A title is a
171
+ sentence — "The verify lock refuses cleanly but cannot say you are queued, or
172
+ that you were displaced" is 90 characters — and a picker truncates it to the
173
+ half that carries no information, so four sessions read as four rows of
174
+ near-identical prose. Worse, it is not a string a person can type: this name
175
+ is also what `ListAgents` lists and `SendMessage` addresses, so it has to be
176
+ something somebody can say out loud to point an agent at one session. The
177
+ item's name is already that — short, unique across the whole board by
178
+ construction, and the same word the person would use for the work itself.
179
+
170
180
  A run is numbered by how many have already worked the item, so the first is
171
- just the title and the fifth says it is the fifth. The number leads because
181
+ just the name and the fifth says it is the fifth. The number leads because
172
182
  that is the column a picker never truncates, and the count comes off
173
183
  `sessions:` rather than being carried in the prompt: the wrap has just
174
184
  recorded the run doing the wrapping, so the successor is the next one along
175
185
  and nothing has to remember what number it is.
176
186
  """
177
187
  ordinal = len(task.sessions) + 1
178
- return task.title if ordinal == 1 else f"{ordinal} · {task.title}"
188
+ return task.name if ordinal == 1 else f"{ordinal} · {task.name}"
179
189
 
180
190
 
181
191
  def cmd_kickoff(args) -> int:
@@ -0,0 +1,141 @@
1
+ {
2
+ "why": [
3
+ "One directory is read by two languages: the harness reads ~/.claude/sessions itself, and",
4
+ "the board's TypeScript reads it again rather than paying a subprocess on every board list.",
5
+ "Two readers of one directory is where one session comes to be described two ways, so both",
6
+ "run THESE cases and assert THIS answer. A drift between them is a red test in both suites",
7
+ "rather than two answers a person has to reconcile.",
8
+ "",
9
+ "A case's `sessions` is null when the directory does not exist at all, which is a different",
10
+ "answer from an empty directory and must stay different: no directory means this client does",
11
+ "not say, and that is where every agent publishing nothing sits.",
12
+ "",
13
+ "`pid` is a marker, never a number: `self` is the running test's own process, which is the",
14
+ "only pid a test can be sure is alive, and `dead` is one nothing can be occupying."
15
+ ],
16
+ "cases": [
17
+ {
18
+ "name": "a run with one live process is reachable by the name it published",
19
+ "sessions": [
20
+ { "file": "1.json", "sessionId": "alive-2222", "name": "repo-a8", "pid": "self", "entrypoint": "cli" }
21
+ ],
22
+ "ask": "alive-2222",
23
+ "expect": {
24
+ "live": "running",
25
+ "processes": 1,
26
+ "addresses": [{ "name": "repo-a8", "from": "a terminal" }]
27
+ }
28
+ },
29
+ {
30
+ "name": "a run whose process is gone is ended, not merely unlisted",
31
+ "sessions": [
32
+ { "file": "1.json", "sessionId": "alive-2222", "name": "repo-a8", "pid": "self", "entrypoint": "cli" },
33
+ { "file": "2.json", "sessionId": "ghost-3333", "name": "repo-b7", "pid": "dead", "entrypoint": "cli" }
34
+ ],
35
+ "ask": "ghost-3333",
36
+ "expect": { "live": "ended", "processes": 0, "addresses": [] }
37
+ },
38
+ {
39
+ "name": "a run nothing published is ended, because something published a list without it",
40
+ "sessions": [
41
+ { "file": "1.json", "sessionId": "alive-2222", "name": "repo-a8", "pid": "self", "entrypoint": "cli" }
42
+ ],
43
+ "ask": "never-heard-of-it",
44
+ "expect": { "live": "ended", "processes": 0, "addresses": [] }
45
+ },
46
+ {
47
+ "name": "a take that named no run at all concludes nothing, because there is nothing to look up",
48
+ "sessions": [
49
+ { "file": "1.json", "sessionId": "alive-2222", "name": "repo-a8", "pid": "self", "entrypoint": "cli" }
50
+ ],
51
+ "ask": "",
52
+ "expect": { "live": "unknown", "processes": 0, "addresses": [] }
53
+ },
54
+ {
55
+ "name": "a client that publishes no list makes every run unknown, never ended",
56
+ "sessions": null,
57
+ "ask": "alive-2222",
58
+ "expect": { "live": "unknown", "processes": 0, "addresses": [] }
59
+ },
60
+ {
61
+ "name": "an empty list is a claim, so a run not on it has ended",
62
+ "sessions": [],
63
+ "ask": "alive-2222",
64
+ "expect": { "live": "ended", "processes": 0, "addresses": [] }
65
+ },
66
+ {
67
+ "name": "two live processes on one id are a fork, named both ways and neither picked",
68
+ "sessions": [
69
+ { "file": "20558.json", "sessionId": "forked-4444", "name": "jarvis-9a", "pid": "self", "entrypoint": "claude-vscode" },
70
+ { "file": "15750.json", "sessionId": "forked-4444", "name": "the harness stops", "pid": "self", "entrypoint": "cli" }
71
+ ],
72
+ "ask": "forked-4444",
73
+ "expect": {
74
+ "live": "forked",
75
+ "processes": 2,
76
+ "addresses": [
77
+ { "name": "jarvis-9a", "from": "an editor" },
78
+ { "name": "the harness stops", "from": "a terminal" }
79
+ ]
80
+ }
81
+ },
82
+ {
83
+ "name": "a fork is counted by its processes even when one of them published no name",
84
+ "sessions": [
85
+ { "file": "1.json", "sessionId": "forked-5555", "name": "named-one", "pid": "self", "entrypoint": "cli" },
86
+ { "file": "2.json", "sessionId": "forked-5555", "name": "", "pid": "self", "entrypoint": "claude-vscode" }
87
+ ],
88
+ "ask": "forked-5555",
89
+ "expect": {
90
+ "live": "forked",
91
+ "processes": 2,
92
+ "addresses": [{ "name": "named-one", "from": "a terminal" }]
93
+ }
94
+ },
95
+ {
96
+ "name": "a live run that published no name is running, with nothing to address it by",
97
+ "sessions": [
98
+ { "file": "1.json", "sessionId": "nameless-6666", "name": "", "pid": "self", "entrypoint": "cli" }
99
+ ],
100
+ "ask": "nameless-6666",
101
+ "expect": { "live": "running", "processes": 1, "addresses": [] }
102
+ },
103
+ {
104
+ "name": "a door this does not know is passed through under the name its client gave it",
105
+ "sessions": [
106
+ { "file": "1.json", "sessionId": "alive-7777", "name": "repo-c1", "pid": "self", "entrypoint": "some-future-client" }
107
+ ],
108
+ "ask": "alive-7777",
109
+ "expect": {
110
+ "live": "running",
111
+ "processes": 1,
112
+ "addresses": [{ "name": "repo-c1", "from": "some-future-client" }]
113
+ }
114
+ },
115
+ {
116
+ "name": "a run that names no door at all is still reachable by name",
117
+ "sessions": [
118
+ { "file": "1.json", "sessionId": "alive-8888", "name": "repo-d2", "pid": "self" }
119
+ ],
120
+ "ask": "alive-8888",
121
+ "expect": {
122
+ "live": "running",
123
+ "processes": 1,
124
+ "addresses": [{ "name": "repo-d2", "from": "" }]
125
+ }
126
+ },
127
+ {
128
+ "name": "a half-written file is skipped rather than read as a dead session",
129
+ "sessions": [
130
+ { "file": "1.json", "sessionId": "alive-9999", "name": "repo-e3", "pid": "self", "entrypoint": "cli" },
131
+ { "file": "2.json", "raw": "{\"sessionId\": \"tru" }
132
+ ],
133
+ "ask": "alive-9999",
134
+ "expect": {
135
+ "live": "running",
136
+ "processes": 1,
137
+ "addresses": [{ "name": "repo-e3", "from": "a terminal" }]
138
+ }
139
+ }
140
+ ]
141
+ }
@@ -29,6 +29,7 @@ import os
29
29
  from pathlib import Path
30
30
 
31
31
  from . import git
32
+ from .tree import cli
32
33
 
33
34
  #: How much of an instance id is worth showing. Eight hex characters is what every
34
35
  #: surface here has always printed and what `ListAgents` matches on sight.
@@ -84,6 +85,12 @@ def alive(pid) -> bool:
84
85
  SESSIONS = "WORK_SESSIONS_DIR"
85
86
  DEFAULT_SESSIONS = "~/.claude/sessions"
86
87
 
88
+ #: What a client calls the door a run came through, in words a person reads. A door
89
+ #: this does not know is passed through under the client's own name, because a name
90
+ #: we have not met still beats a guess — and there must be nothing here a client is
91
+ #: REQUIRED to publish for the rest of the answer to hold.
92
+ DOORS = {"cli": "a terminal", "claude-vscode": "an editor"}
93
+
87
94
  #: Answers already worked out this run, keyed by the directory they came from. Keyed
88
95
  #: rather than a single slot because the tests point this at several directories in
89
96
  #: one process, and a cache that ignored where it read from would answer for the
@@ -95,6 +102,14 @@ def _live():
95
102
  """Every agent session live on THIS machine, keyed by session id, as the client
96
103
  published it — or `None` when nothing here publishes that at all.
97
104
 
105
+ The value is a LIST because one session id can be more than one live process.
106
+ Opening a running session in a second local client starts a second process on the
107
+ same id rather than attaching to the first, so both are alive and both append to
108
+ one transcript. Keying one entry per id silently dropped whichever file sorted
109
+ earlier, which made the name handed to a reader depend on how two pids happened to
110
+ sort — measured on this machine, five of seventeen live ids are doubled, and which
111
+ half won flipped from pair to pair.
112
+
98
113
  The whole entry rather than one field of it, because two questions are asked of
99
114
  this directory now: who is running, and who is running IN THIS CHECKOUT. They
100
115
  want the same scan, the same pid check and the same cache, and reading the
@@ -139,22 +154,44 @@ def _live():
139
154
  run = str(entry.get("sessionId", ""))
140
155
  if not run or not alive(entry.get("pid")):
141
156
  continue
142
- out[run] = entry
157
+ out.setdefault(run, []).append(entry)
143
158
  _RUNNING[key] = out
144
159
  return out
145
160
 
146
161
 
162
+ def addresses(entries) -> list:
163
+ """What reaches each of a session's live processes: `(name, door)` pairs, sorted.
164
+
165
+ A process that publishes no name is left out rather than listed blank — naming it
166
+ would send a reader looking for something `SendMessage` will not take.
167
+ """
168
+ out = []
169
+ for entry in entries:
170
+ name = str(entry.get("name", "")).strip()
171
+ if not name:
172
+ continue
173
+ door = str(entry.get("entrypoint", "")).strip()
174
+ out.append((name, DOORS.get(door, door)))
175
+ out.sort()
176
+ return out
177
+
178
+
147
179
  def running():
148
- """Every agent session live on THIS machine as `{session id: name}`.
180
+ """Every agent session live on THIS machine as `{session id: [name, ...]}`.
149
181
 
150
182
  `None` and `{}` stay as far apart here as they are in `_live`: no directory
151
183
  means *this client does not say*, and an empty mapping means something
152
184
  published a list this session was not on.
185
+
186
+ The names are a LIST for the same reason `_live` holds one: an id answering to two
187
+ names is a session running as two processes, and picking one of them is the defect
188
+ this shape exists to make impossible.
153
189
  """
154
190
  live = _live()
155
191
  if live is None:
156
192
  return None
157
- return {run: str(entry.get("name", "")).strip() for run, entry in live.items()}
193
+ return {run: [name for name, _ in addresses(entries)]
194
+ for run, entries in live.items()}
158
195
 
159
196
 
160
197
  def _inside(where: str, root) -> bool:
@@ -195,24 +232,65 @@ def sharing(root) -> str:
195
232
  if not live:
196
233
  return ""
197
234
  mine = me()
198
- names = []
199
- for run, entry in live.items():
200
- if run == mine or not _inside(str(entry.get("cwd", "")), root):
235
+ names, runs = [], 0
236
+ for run, entries in live.items():
237
+ if run == mine:
201
238
  continue
202
- name = str(entry.get("name", "")).strip()
203
- # A session with no published name cannot be addressed, so naming it would
204
- # send a reader looking for something `SendMessage` will not take.
205
- if name:
206
- names.append(name)
239
+ found = addresses([e for e in entries
240
+ if _inside(str(e.get("cwd", "")), root)])
241
+ if not found:
242
+ continue
243
+ runs += 1
244
+ names.extend(name for name, _ in found)
207
245
  names.sort()
208
246
  if not names:
209
247
  return ""
210
248
  shown = ", ".join(f"`{n}`" for n in names)
211
- one = len(names) == 1
212
- return (f"{len(names)} other session{'' if one else 's'} "
249
+ one = runs == 1
250
+ line = (f"{runs} other session{'' if one else 's'} "
213
251
  f"{'is' if one else 'are'} live in this checkout — {shown}. "
214
252
  f"`SendMessage` reaches {'it' if one else 'them'}; ask before you stash "
215
253
  f"or commit anything you did not write.")
254
+ # More names than sessions means one of them is running as two processes, which
255
+ # is a thing a reader about to message somebody needs to know: either name
256
+ # arrives at the same conversation, so picking between them decides nothing.
257
+ if len(names) > runs:
258
+ line += (" More names than sessions: one of these is running as two "
259
+ "processes sharing a transcript, so either of its names reaches it.")
260
+ return line
261
+
262
+
263
+ def reach(instance: str, host: str = "") -> dict:
264
+ """Whether a run is live and what reaches it — `{"live": …, "addresses": […]}`.
265
+
266
+ The structured answer, which `describe` renders into a sentence and the board's
267
+ TypeScript computes for itself. Both are pinned to one set of cases, because two
268
+ readers of one directory in two languages is exactly where the same session comes
269
+ to be described two ways, and a drift between them should be a red test rather
270
+ than two answers a person has to reconcile.
271
+
272
+ Four states, and the difference between two of them is the whole point. `ended`
273
+ is a CLAIM — something published a list and this run was not on it. `unknown` is
274
+ the absence of a claim: nothing here publishes one, or the run is on a machine
275
+ this one cannot see into. Collapsing them reports every peer of every client that
276
+ publishes nothing as dead, which is worse than the silence it replaces.
277
+ """
278
+ who = (instance or "").strip()
279
+ away = bool(host.strip()) and bool(here()) and host.strip() != here()
280
+ live = None if away else _live()
281
+ # A take that named no run at all is not a run that has ended — there is nothing to
282
+ # look up, so there is nothing to conclude. Reading the miss as `ended` would have
283
+ # every board write made without a session id report as abandoned work.
284
+ if live is None or not who:
285
+ return {"live": "unknown", "processes": 0, "addresses": []}
286
+ entries = live.get(who)
287
+ if not entries:
288
+ return {"live": "ended", "processes": 0, "addresses": []}
289
+ # Counted from the processes, never from the addresses: one of them publishing no
290
+ # name would otherwise make a fork report as though it were a single run.
291
+ return {"live": "forked" if len(entries) > 1 else "running",
292
+ "processes": len(entries),
293
+ "addresses": [{"name": n, "from": d} for n, d in addresses(entries)]}
216
294
 
217
295
 
218
296
  def describe(instance: str, host: str = "") -> str:
@@ -239,19 +317,28 @@ def describe(instance: str, host: str = "") -> str:
239
317
  if box and here() and box != here():
240
318
  return (f"{short} on {box} — another machine, so it cannot be reached from "
241
319
  f"here; the board is the only thing you share")
242
- live = running()
243
- if live is None:
320
+ got = reach(who)
321
+ found = got["addresses"]
322
+ if got["live"] == "unknown":
244
323
  return (f"{short} — this machine: `ListAgents` lists it, `SendMessage` reaches "
245
324
  f"it if the plan needs to change")
246
- if who not in live:
325
+ if got["live"] == "ended":
247
326
  return (f"{short} — this machine, but that session has ENDED: there is "
248
327
  f"nobody to reach, so this is abandoned rather than held")
249
- name = live[who]
250
- if not name:
328
+ if got["live"] == "forked":
329
+ if not found:
330
+ return (f"{short} — this machine, running as more than one process on ONE "
331
+ f"session, none of which publishes a name to address it by")
332
+ shown = ", ".join(f"`{a['name']}`" + (f" ({a['from']})" if a["from"] else "")
333
+ for a in found)
334
+ return (f"{short} — this machine, running as {got['processes']} processes on "
335
+ f"ONE session: {shown}. They share a transcript, so either reaches it "
336
+ f"and neither is THE address")
337
+ if not found:
251
338
  return (f"{short} — this machine and still running, though it publishes no "
252
339
  f"name to address it by")
253
- return (f"`{name}` ({short}) — this machine and still running: `SendMessage` "
254
- f"reaches it if the plan needs to change")
340
+ return (f"`{found[0]['name']}` ({short}) — this machine and still running: "
341
+ f"`SendMessage` reaches it if the plan needs to change")
255
342
 
256
343
 
257
344
  def of_claim(claim: dict) -> str:
@@ -260,6 +347,88 @@ def of_claim(claim: dict) -> str:
260
347
  return describe(str(claim.get("instance", "")), str(claim.get("machine", "")))
261
348
 
262
349
 
350
+ def briefing(root, rows: list, branch: str = "") -> str:
351
+ """Who else is working this checkout right now and what they are on, or `""`.
352
+
353
+ The line a session is handed before it does anything, and the answer a session
354
+ gets when it asks again. Silence when you are alone is the design rather than an
355
+ optimisation: every session in every repo running this harness reads this forever,
356
+ so it costs nothing on the ordinary day and earns its place on the day it matters.
357
+
358
+ `rows` is the board's own history, so what a peer holds comes from the commits
359
+ that took it rather than from a second store. It is passed in because the caller
360
+ already has it, and because reading it here would make this module import the one
361
+ that imports it.
362
+
363
+ Machine and branch are answered by construction and are not looked up per peer: a
364
+ session live in THIS checkout is on this machine and on whatever branch the
365
+ checkout is on. Saying so once is the whole of that answer.
366
+ """
367
+ live = _live()
368
+ if not live:
369
+ return ""
370
+ mine = me()
371
+ # The last board write each session made, which is the item it is on. A later
372
+ # write by somebody else does not move it: two sessions can hold one item.
373
+ held = {}
374
+ for row in rows:
375
+ by, name = str(row.get("by", "")), str(row.get("name", ""))
376
+ if by and name:
377
+ held[by] = name
378
+ said = []
379
+ for run in sorted(live):
380
+ if run == mine:
381
+ continue
382
+ found = addresses([e for e in live[run]
383
+ if _inside(str(e.get("cwd", "")), root)])
384
+ if not found:
385
+ continue
386
+ shown = ", ".join(f"`{n}`" + (f" ({d})" if d else "") for n, d in found)
387
+ on = held.get(run)
388
+ note = " — TWO processes on one session, sharing a transcript" \
389
+ if len(found) > 1 else ""
390
+ said.append(f"{shown} · {'on ' + on if on else 'holding nothing on the board'}"
391
+ f"{note}")
392
+ if not said:
393
+ return ""
394
+ where = f" on branch {branch}" if branch else ""
395
+ return (f"somebody else is working this checkout{where}, on this machine: "
396
+ + " · ".join(said)
397
+ + f". `SendMessage` reaches them by name. This is true as it prints and "
398
+ f"not for long — `{cli()} peers` asks again. Before you take work that "
399
+ f"overlaps theirs, ask them.")
400
+
401
+
402
+ def cmd_peers(args=None) -> int:
403
+ """Ask again who else is here. The session block says it once at startup; this is
404
+ the same answer, now.
405
+
406
+ It ALWAYS prints, where the startup line says nothing when you are alone — a hook
407
+ that stays quiet costs a session nothing, while a command that answers with an
408
+ empty screen reads as broken rather than as an answer.
409
+ """
410
+ from . import events, git
411
+ from .tree import find_work_root
412
+ root = find_work_root()
413
+ repo = root.parent
414
+ try:
415
+ rows = events.read(root)
416
+ except (OSError, ValueError):
417
+ rows = []
418
+ said = briefing(repo, rows, git.branch(repo))
419
+ if said:
420
+ print(said)
421
+ return 0
422
+ live = _live()
423
+ # The two silences are different answers and only one of them is good news.
424
+ if live is None:
425
+ print("nothing on this machine publishes a session list, so who else is here "
426
+ "cannot be told from here — not that nobody is.")
427
+ else:
428
+ print("nobody else is working this checkout.")
429
+ return 0
430
+
431
+
263
432
  def arrivals(rows: list) -> str:
264
433
  """What just landed on the board from somewhere else, as one line.
265
434
 
@@ -51,6 +51,11 @@ repo here has ONE server named `jarvis` and it is that repo's own — a checked-
51
51
 
52
52
  **The loop, in order:**
53
53
 
54
+ 0. **Who else is here.** The session block names them at startup and says nothing when you are
55
+ alone; `jarvis work peers` asks again, and it is the one answer in this loop that is stale the
56
+ moment it prints. Do it before you scope, because the cheapest collision to avoid is the one
57
+ where two sessions plan the same edit set — see §Somebody else is on it for what to do when the
58
+ answer is *somebody, on your regions*.
54
59
  1. **`map_brief {}`** — orient. What is in flight, the most-cited rules, health, and **whether the
55
60
  index is fresh**.
56
61
  2. **`map_scope {task}`** — the task in words → the edit set, the governing rules, the covering tests
@@ -503,15 +508,46 @@ them**:
503
508
 
504
509
  | You see it | When |
505
510
  |---|---|
511
+ | the session block, at startup | somebody else is live in this checkout — who, on what, and how to reach them. **Silence means you are alone** |
512
+ | `jarvis work peers` | you asked again, because that line was true when it printed and not for long |
506
513
  | `next` skips a task | its `code:` regions are held by another session |
507
514
  | `move <name> in-progress` | a live claim on it belongs to somebody else — a **note**, not a refusal |
508
515
  | `status` → IN FLIGHT | a task is held by a session that is not you |
516
+ | a board read | every holder carries whether its run is still going and what reaches it |
509
517
  | any write | a pull brought board changes in: *the board moved under you — <what> · from <who>* |
510
518
 
511
519
  - **`— this machine`** → that session is addressable. `ListAgents` lists it, `SendMessage` reaches it.
512
520
  Use it when the plan has to change and waiting for a lease to expire is the wrong answer.
513
521
  - **`— another machine`** → it cannot be reached from here. The board is the only thing you share, so
514
522
  say it on the board: `ask`, a `handoff`, or a task note.
523
+ - **`ENDED`** → that is abandoned work, not a peer to negotiate with. Nobody is there to ask, so the
524
+ decision is yours alone.
525
+ - **two processes on ONE session** → opening a running session in a second local client starts a
526
+ second process on the same id rather than attaching to the first. Both are alive and both append to
527
+ one transcript, so **either name reaches the same conversation** — you are handed both precisely
528
+ because neither is THE address.
529
+
530
+ ### The three answers, and what to do with each
531
+
532
+ **Nobody.** Carry on. You were told, and a session that was told nothing is not the same as one that
533
+ never looked.
534
+
535
+ **Somebody, reachable.** Ask them before you scope, not after you collide — one message costs a turn,
536
+ and the collision costs a re-read or a whole gate run. Say what your edit set is and ask for theirs.
537
+ Then **split by GOAL, never by file**: two sessions carving one goal into halves produce two halves
538
+ nobody can ship. If their goal contains yours, hand it over rather than taking it — a `handoff` and a
539
+ message is cheaper than two people converging on one answer from opposite directions. If your edit
540
+ sets genuinely overlap, **sequence and say so**: commit in chunks, tell them when a shared tree is
541
+ clear, and do not interleave.
542
+
543
+ **Somebody, on another machine.** You cannot reach them at all, so the board is the whole of what you
544
+ share. Put it there — `ask`, a note on the task, a `handoff` — and then decide for yourself. Waiting
545
+ on somebody who cannot hear you is not caution.
546
+
547
+ **Nothing here refuses you, and nothing here is permission.** Taking always succeeds; you are told
548
+ who else is there and you decide. A harness that argues gets worked around — the one mechanism in
549
+ this repo that refuses produced a hand-written forty-iteration retry loop — so the whole of this is
550
+ *tell clearly, never refuse*.
515
551
 
516
552
  **The harness never messages anyone.** It reports who and whether they are reachable; opening the
517
553
  conversation is your call. A `move` that warns still moves — `next` is the door that declines held