@appchy/jarvis 0.1.101 → 0.1.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +1 -1
- package/harness/harness/gate.py +10 -1
- package/harness/harness/peers.py +80 -4
- package/harness/test_work.py +69 -0
- package/package.json +5 -5
package/dist/bin.js
CHANGED
|
@@ -10260,7 +10260,7 @@ import { createRequire as createRequire2 } from "module";
|
|
|
10260
10260
|
var _require = createRequire2(import.meta.url);
|
|
10261
10261
|
var VERSION2 = _require("../package.json").version ?? "0.0.0";
|
|
10262
10262
|
var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
|
|
10263
|
-
var SHA = "
|
|
10263
|
+
var SHA = "2ef6df4";
|
|
10264
10264
|
var BUILT = "2026-09-11";
|
|
10265
10265
|
var BUILD = SHA ?? "source";
|
|
10266
10266
|
var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
|
package/harness/harness/gate.py
CHANGED
|
@@ -677,13 +677,22 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
|
|
|
677
677
|
if loose:
|
|
678
678
|
shown = ", ".join(loose[:5])
|
|
679
679
|
more = f" and {len(loose) - 5} more" if len(loose) > 5 else ""
|
|
680
|
+
# WHO ELSE IS HERE, on the one refusal most likely to be about somebody
|
|
681
|
+
# else's file. A shared checkout is normal now, and the advice above
|
|
682
|
+
# ("commit them, or stash what is not this task's") is advice about
|
|
683
|
+
# another session's unsaved work — so a session that acts on it alone
|
|
684
|
+
# destroys work nobody can recover. Naming the peers turns "go and find
|
|
685
|
+
# out who exists" into one message. It cannot say WHICH of them touched
|
|
686
|
+
# a file: git does not record that and neither does anything else here.
|
|
687
|
+
others = peers.sharing(root.parent)
|
|
680
688
|
reasons.append(
|
|
681
689
|
f"{len(loose)} uncommitted change(s) outside the board — the gates "
|
|
682
690
|
f"ran against this working tree, so completing now would record "
|
|
683
691
|
f"shipped for code no commit contains: {shown}{more}. Commit them "
|
|
684
692
|
f"(or stash or gitignore what is not this task's), then re-run "
|
|
685
693
|
f"`jarvis work verify --task {task.name}` — committing moves HEAD, "
|
|
686
|
-
f"so the evidence has to be taken on the tree that shipped."
|
|
694
|
+
f"so the evidence has to be taken on the tree that shipped."
|
|
695
|
+
+ (f" {others}" if others else ""))
|
|
687
696
|
else:
|
|
688
697
|
reasons.append("no `verify` commands configured — an unconfigured repo "
|
|
689
698
|
"cannot prove anything, so nothing in it can complete. Set "
|
package/harness/harness/peers.py
CHANGED
|
@@ -60,9 +60,14 @@ DEFAULT_SESSIONS = "~/.claude/sessions"
|
|
|
60
60
|
_RUNNING = {}
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
def
|
|
64
|
-
"""Every agent session live on THIS machine
|
|
65
|
-
when nothing here publishes that at all.
|
|
63
|
+
def _live():
|
|
64
|
+
"""Every agent session live on THIS machine, keyed by session id, as the client
|
|
65
|
+
published it — or `None` when nothing here publishes that at all.
|
|
66
|
+
|
|
67
|
+
The whole entry rather than one field of it, because two questions are asked of
|
|
68
|
+
this directory now: who is running, and who is running IN THIS CHECKOUT. They
|
|
69
|
+
want the same scan, the same pid check and the same cache, and reading the
|
|
70
|
+
directory twice would let one answer be true while the other was stale.
|
|
66
71
|
|
|
67
72
|
The `None` is the whole point and must not be flattened into an empty dict. No
|
|
68
73
|
directory means *this client does not say*, which is where Codex and Gemini sit
|
|
@@ -119,11 +124,82 @@ def running():
|
|
|
119
124
|
continue
|
|
120
125
|
except PermissionError:
|
|
121
126
|
pass # alive and owned by somebody else, which is still alive
|
|
122
|
-
out[run] =
|
|
127
|
+
out[run] = entry
|
|
123
128
|
_RUNNING[key] = out
|
|
124
129
|
return out
|
|
125
130
|
|
|
126
131
|
|
|
132
|
+
def running():
|
|
133
|
+
"""Every agent session live on THIS machine as `{session id: name}`.
|
|
134
|
+
|
|
135
|
+
`None` and `{}` stay as far apart here as they are in `_live`: no directory
|
|
136
|
+
means *this client does not say*, and an empty mapping means something
|
|
137
|
+
published a list this session was not on.
|
|
138
|
+
"""
|
|
139
|
+
live = _live()
|
|
140
|
+
if live is None:
|
|
141
|
+
return None
|
|
142
|
+
return {run: str(entry.get("name", "")).strip() for run, entry in live.items()}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _inside(where: str, root) -> bool:
|
|
146
|
+
"""Is `where` the same place as `root`, or somewhere under it?
|
|
147
|
+
|
|
148
|
+
Both sides are resolved because a session publishes the path it was started
|
|
149
|
+
with and macOS hands out two names for the same directory — `/tmp/x` and
|
|
150
|
+
`/private/tmp/x` — so comparing the strings reports two sessions in one
|
|
151
|
+
checkout as being in different ones.
|
|
152
|
+
"""
|
|
153
|
+
if not where:
|
|
154
|
+
return False
|
|
155
|
+
try:
|
|
156
|
+
here_ = Path(where).resolve()
|
|
157
|
+
there = Path(root).resolve()
|
|
158
|
+
except (OSError, ValueError):
|
|
159
|
+
return False
|
|
160
|
+
return here_ == there or there in here_.parents
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def sharing(root) -> str:
|
|
164
|
+
"""The OTHER live sessions working inside `root`, as one line, or `""`.
|
|
165
|
+
|
|
166
|
+
Empty when nothing publishes a session list, when this session is the only one
|
|
167
|
+
here, or when the peers here publish no name — in each case there is nobody a
|
|
168
|
+
reader could go and talk to, and a line saying so is noise on a message that is
|
|
169
|
+
already refusing something.
|
|
170
|
+
|
|
171
|
+
**It names who is here, never who touched what.** Git cannot attribute an
|
|
172
|
+
uncommitted file to a session; nothing on this machine can. So the line says
|
|
173
|
+
which sessions share this checkout and leaves the asking to the person or the
|
|
174
|
+
agent, which is the honest half and the one that was missing — the cost being
|
|
175
|
+
paid today is not that the answer is unknowable, it is that a session refused
|
|
176
|
+
for somebody else's file has to go and find out who else exists before it can
|
|
177
|
+
even ask.
|
|
178
|
+
"""
|
|
179
|
+
live = _live()
|
|
180
|
+
if not live:
|
|
181
|
+
return ""
|
|
182
|
+
mine = me()
|
|
183
|
+
names = []
|
|
184
|
+
for run, entry in live.items():
|
|
185
|
+
if run == mine or not _inside(str(entry.get("cwd", "")), root):
|
|
186
|
+
continue
|
|
187
|
+
name = str(entry.get("name", "")).strip()
|
|
188
|
+
# A session with no published name cannot be addressed, so naming it would
|
|
189
|
+
# send a reader looking for something `SendMessage` will not take.
|
|
190
|
+
if name:
|
|
191
|
+
names.append(name)
|
|
192
|
+
names.sort()
|
|
193
|
+
if not names:
|
|
194
|
+
return ""
|
|
195
|
+
shown = ", ".join(f"`{n}`" for n in names)
|
|
196
|
+
one = len(names) == 1
|
|
197
|
+
return (f"{len(names)} other session{'' if one else 's'} "
|
|
198
|
+
f"{'is' if one else 'are'} live in this checkout — {shown}. "
|
|
199
|
+
f"`SendMessage` reaches {'it' if one else 'them'}; ask before you stash "
|
|
200
|
+
f"or commit anything you did not write.")
|
|
201
|
+
|
|
202
|
+
|
|
127
203
|
def describe(instance: str, host: str = "") -> str:
|
|
128
204
|
"""`instance` (and the machine it sits on) as a line that says what to do next.
|
|
129
205
|
|
package/harness/test_work.py
CHANGED
|
@@ -4400,6 +4400,75 @@ def test_a_held_task_names_a_session_you_can_actually_reach():
|
|
|
4400
4400
|
os.environ.pop(key, None)
|
|
4401
4401
|
|
|
4402
4402
|
|
|
4403
|
+
def test_a_refusal_about_a_shared_tree_names_who_else_is_in_it():
|
|
4404
|
+
# The completion gate refuses on uncommitted code and then advises stashing it
|
|
4405
|
+
# — which, in a checkout several sessions share, is advice about somebody
|
|
4406
|
+
# else's unsaved work. A session acting on it alone destroys work with no
|
|
4407
|
+
# reflog entry. So the refusal names the peers; finding out who exists was the
|
|
4408
|
+
# cost being paid, not the answer being unknowable.
|
|
4409
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
4410
|
+
try:
|
|
4411
|
+
here = Path(tmp) / "repo"
|
|
4412
|
+
(here / "src").mkdir(parents=True)
|
|
4413
|
+
elsewhere = Path(tmp) / "other-repo"
|
|
4414
|
+
elsewhere.mkdir()
|
|
4415
|
+
reg = Path(tmp) / "sessions"
|
|
4416
|
+
reg.mkdir()
|
|
4417
|
+
os.environ["WORK_INSTANCE"] = "mine-0000"
|
|
4418
|
+
os.environ["WORK_MACHINE"] = "this-box"
|
|
4419
|
+
os.environ["WORK_SESSIONS_DIR"] = str(reg)
|
|
4420
|
+
peers._RUNNING.clear()
|
|
4421
|
+
|
|
4422
|
+
def publish(n, run, name, cwd, pid=None):
|
|
4423
|
+
(reg / f"{n}.json").write_text(json.dumps(
|
|
4424
|
+
{"sessionId": run, "name": name, "cwd": str(cwd),
|
|
4425
|
+
"pid": os.getpid() if pid is None else pid}))
|
|
4426
|
+
|
|
4427
|
+
# Me, so I am never named to myself; a peer deeper inside the same
|
|
4428
|
+
# checkout, because a session started in a subdirectory shares the tree
|
|
4429
|
+
# exactly as much; a session in a different repo; a dead one; and one
|
|
4430
|
+
# live here that publishes no name, which cannot be addressed.
|
|
4431
|
+
publish(1, "mine-0000", "repo-me", here / "src")
|
|
4432
|
+
publish(2, "peer-1111", "repo-a8", here / "src")
|
|
4433
|
+
publish(3, "away-2222", "repo-zz", elsewhere)
|
|
4434
|
+
publish(4, "ghost-3333", "repo-b7", here, pid=2 ** 22)
|
|
4435
|
+
publish(5, "mute-4444", "", here)
|
|
4436
|
+
|
|
4437
|
+
line = peers.sharing(here)
|
|
4438
|
+
assert "repo-a8" in line, "a live peer in this checkout has to be named"
|
|
4439
|
+
assert "1 other session is" in line, \
|
|
4440
|
+
"me, another repo, a ghost and an unaddressable one are all not peers here"
|
|
4441
|
+
for absent in ("repo-me", "repo-zz", "repo-b7"):
|
|
4442
|
+
assert absent not in line, f"{absent} is not somebody to ask"
|
|
4443
|
+
|
|
4444
|
+
# A second live peer makes it plural, and they are named in a stable
|
|
4445
|
+
# order — a message that reshuffles reads as new information.
|
|
4446
|
+
publish(6, "peer-5555", "repo-c9", here)
|
|
4447
|
+
peers._RUNNING.clear()
|
|
4448
|
+
both = peers.sharing(here)
|
|
4449
|
+
assert "2 other sessions are" in both and both.index("repo-a8") < both.index("repo-c9")
|
|
4450
|
+
|
|
4451
|
+
# A checkout nobody else is in is SILENCE, not a line saying so: this
|
|
4452
|
+
# rides on a message that is already refusing something.
|
|
4453
|
+
empty = Path(tmp) / "nobody-here"
|
|
4454
|
+
empty.mkdir()
|
|
4455
|
+
peers._RUNNING.clear()
|
|
4456
|
+
assert peers.sharing(empty) == ""
|
|
4457
|
+
# …and `elsewhere` is not silent, because a session really is in it.
|
|
4458
|
+
# The scoping is by checkout, not by "anywhere but mine".
|
|
4459
|
+
assert "repo-zz" in peers.sharing(elsewhere)
|
|
4460
|
+
|
|
4461
|
+
# And a client that publishes nothing at all must not read as an empty
|
|
4462
|
+
# room — it is the same `None` that stops every peer looking dead.
|
|
4463
|
+
os.environ["WORK_SESSIONS_DIR"] = str(Path(tmp) / "nothing-here")
|
|
4464
|
+
peers._RUNNING.clear()
|
|
4465
|
+
assert peers.running() is None and peers.sharing(here) == ""
|
|
4466
|
+
finally:
|
|
4467
|
+
for key in ("WORK_INSTANCE", "WORK_MACHINE", "WORK_SESSIONS_DIR"):
|
|
4468
|
+
os.environ.pop(key, None)
|
|
4469
|
+
peers._RUNNING.clear()
|
|
4470
|
+
|
|
4471
|
+
|
|
4403
4472
|
def test_how_long_ago_is_said_in_the_unit_that_changes_the_decision():
|
|
4404
4473
|
# The question this answers is *is anybody on this*, and the reader is deciding
|
|
4405
4474
|
# whether to take the work. "6h ago" settles that; a timestamp makes them do
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.102",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -57,16 +57,16 @@
|
|
|
57
57
|
"typescript": "^5.7.0",
|
|
58
58
|
"vitest": "^2.1.0",
|
|
59
59
|
"@jarvis/agents": "1.0.0",
|
|
60
|
-
"@jarvis/data": "0.1.0",
|
|
61
|
-
"@jarvis/anthropic": "1.0.0",
|
|
62
60
|
"@jarvis/board": "0.1.0",
|
|
61
|
+
"@jarvis/anthropic": "1.0.0",
|
|
63
62
|
"@jarvis/errors": "1.0.0",
|
|
64
63
|
"@jarvis/logger": "1.0.0",
|
|
64
|
+
"@jarvis/data": "0.1.0",
|
|
65
65
|
"@jarvis/rpc": "1.0.0",
|
|
66
66
|
"@jarvis/types": "1.0.0",
|
|
67
|
-
"@jarvis/
|
|
67
|
+
"@jarvis/typescript-config": "1.0.0",
|
|
68
68
|
"@jarvis/vitest-config": "1.0.0",
|
|
69
|
-
"@jarvis/
|
|
69
|
+
"@jarvis/ui": "0.1.0"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|
|
72
72
|
"dev": "tsx watch src/bin.ts start --foreground",
|