@appchy/jarvis 0.1.118 → 0.1.120
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/git.py +106 -8
- package/harness/test_work.py +105 -0
- package/harness/work.py +12 -1
- package/package.json +2 -2
package/dist/bin.js
CHANGED
|
@@ -10284,7 +10284,7 @@ import { createRequire as createRequire2 } from "module";
|
|
|
10284
10284
|
var _require = createRequire2(import.meta.url);
|
|
10285
10285
|
var VERSION2 = _require("../package.json").version ?? "0.0.0";
|
|
10286
10286
|
var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
|
|
10287
|
-
var SHA = "
|
|
10287
|
+
var SHA = "9022153";
|
|
10288
10288
|
var BUILT = "2026-09-12";
|
|
10289
10289
|
var BUILD = SHA ?? "source";
|
|
10290
10290
|
var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
|
package/harness/harness/git.py
CHANGED
|
@@ -502,6 +502,94 @@ def changed(repo) -> tuple:
|
|
|
502
502
|
return paths, untracked
|
|
503
503
|
|
|
504
504
|
|
|
505
|
+
#: The buckets a task folder sits in. A path's item is the segment AFTER one of
|
|
506
|
+
#: these, which is how a file is traced back to the work it belongs to without a
|
|
507
|
+
#: registry that could disagree with the tree.
|
|
508
|
+
_BUCKETS = ("queue", "in-progress", "blocked", "complete")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def dirty(repo) -> frozenset:
|
|
512
|
+
"""What is already uncommitted under the configured paths, right now.
|
|
513
|
+
|
|
514
|
+
Taken before a command runs so the commit afterwards can tell what that command
|
|
515
|
+
wrote from what was lying there when it started. Cheap — one `git status`.
|
|
516
|
+
"""
|
|
517
|
+
if not is_repo(repo):
|
|
518
|
+
return frozenset()
|
|
519
|
+
return frozenset(changed(repo)[0])
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def item_of(path: str) -> str:
|
|
523
|
+
"""The work item a path belongs to, or "" for anything that is not a task file.
|
|
524
|
+
|
|
525
|
+
Governance docs, a version.md and an epic.md all answer "" — they belong to the
|
|
526
|
+
tree rather than to one item, so nothing can claim to have authored them by
|
|
527
|
+
holding something.
|
|
528
|
+
"""
|
|
529
|
+
parts = Path(path).parts
|
|
530
|
+
for i, part in enumerate(parts[:-1]):
|
|
531
|
+
# A cut holds buckets and the item is the folder inside one. The backlog
|
|
532
|
+
# holds no buckets at either tier — it is `backlog/<epic>/<item>/` — so the
|
|
533
|
+
# item sits one deeper, and reading it as a bucket would name the epic.
|
|
534
|
+
step = 2 if part == "backlog" else (1 if part in _BUCKETS else 0)
|
|
535
|
+
if step and i + step < len(parts) - 1:
|
|
536
|
+
return parts[i + step]
|
|
537
|
+
return ""
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def authored(paths, before, item: str) -> tuple:
|
|
541
|
+
"""Split what is uncommitted into (mine, theirs).
|
|
542
|
+
|
|
543
|
+
MINE is what this command wrote — anything that became dirty while it ran — plus
|
|
544
|
+
everything under the item it acted on, because a session that holds an item and
|
|
545
|
+
hand-edits its brief did author that, whenever it typed it.
|
|
546
|
+
|
|
547
|
+
THEIRS is the rest, and it is left alone. It used to be committed too, under
|
|
548
|
+
whoever ran next: the pathspec took the whole of `work/`, so a peer's
|
|
549
|
+
half-written prose landed inside somebody else's commit, stamped with their
|
|
550
|
+
session and their item. That made the record assert two things that were not
|
|
551
|
+
true — who wrote it, and what it was work on — and both are read downstream to
|
|
552
|
+
answer who is on an item.
|
|
553
|
+
|
|
554
|
+
What this deliberately does NOT do is narrow the pathspec to the command's own
|
|
555
|
+
writes alone. A brief edited by hand before the command that files it would then
|
|
556
|
+
never be committed by anything, which is the hole that made the sweep the right
|
|
557
|
+
answer in the first place.
|
|
558
|
+
"""
|
|
559
|
+
mine, theirs = [], []
|
|
560
|
+
for p in paths:
|
|
561
|
+
if p not in before or (item and item_of(p) == item):
|
|
562
|
+
mine.append(p)
|
|
563
|
+
else:
|
|
564
|
+
theirs.append(p)
|
|
565
|
+
return mine, theirs
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _left_alone(theirs) -> str:
|
|
569
|
+
"""What to say about work this commit did not take.
|
|
570
|
+
|
|
571
|
+
Named file by file with the item each belongs to, because "some files were left"
|
|
572
|
+
is not something a session can act on and this is meant to be resolved in the
|
|
573
|
+
turn it appears, not noted. A session reading it either commits them — they are
|
|
574
|
+
often its own, typed before the command that triggered this — or leaves them to
|
|
575
|
+
the session working that item, which is now a decision somebody makes rather
|
|
576
|
+
than one the pathspec made for them.
|
|
577
|
+
"""
|
|
578
|
+
if not theirs:
|
|
579
|
+
return ""
|
|
580
|
+
lines = []
|
|
581
|
+
for p in sorted(theirs)[:10]:
|
|
582
|
+
owner = item_of(p)
|
|
583
|
+
lines.append(f" {p}" + (f" ({owner})" if owner else " (no item — a hand edit)"))
|
|
584
|
+
more = len(theirs) - len(lines)
|
|
585
|
+
if more > 0:
|
|
586
|
+
lines.append(f" … and {more} more")
|
|
587
|
+
return ("left uncommitted — this write did not author them, so it did not take "
|
|
588
|
+
"them:\n" + "\n".join(lines) + "\n Commit them yourself if they are "
|
|
589
|
+
"yours; if another session is working that item, they are that session's "
|
|
590
|
+
"to land.")
|
|
591
|
+
|
|
592
|
+
|
|
505
593
|
def only_board_moved(repo, sha: str) -> bool:
|
|
506
594
|
"""Is everything between `sha` and HEAD a board file?
|
|
507
595
|
|
|
@@ -572,12 +660,17 @@ def moved_only_elsewhere(repo, sha: str, root, regions: list) -> bool:
|
|
|
572
660
|
return moved
|
|
573
661
|
|
|
574
662
|
|
|
575
|
-
def land(repo, item: str, rows: list) -> tuple:
|
|
576
|
-
"""Commit what
|
|
663
|
+
def land(repo, item: str, rows: list, before=frozenset()) -> tuple:
|
|
664
|
+
"""Commit what this command wrote and push it. Returns (committed, pushed, note).
|
|
665
|
+
|
|
666
|
+
`before` is what was already uncommitted when the command started, so the commit
|
|
667
|
+
can take what it authored and leave what it did not — see `authored`. Passing
|
|
668
|
+
nothing keeps the old behaviour of taking everything, which is right for a caller
|
|
669
|
+
that has no earlier moment to compare against.
|
|
577
670
|
|
|
578
|
-
`note` is what to tell the caller — empty when everything landed
|
|
579
|
-
cannot land is NOT a failure of the write: the change is
|
|
580
|
-
what makes it unloseable, and the note says how to send it.
|
|
671
|
+
`note` is what to tell the caller — empty when everything landed and nothing was
|
|
672
|
+
left behind. A push that cannot land is NOT a failure of the write: the change is
|
|
673
|
+
committed, which is what makes it unloseable, and the note says how to send it.
|
|
581
674
|
"""
|
|
582
675
|
# Asked FIRST, and not after `changed`: a directory that is not a repo makes
|
|
583
676
|
# `git status` fail, `changed` return nothing, and the write look like a no-op
|
|
@@ -590,6 +683,9 @@ def land(repo, item: str, rows: list) -> tuple:
|
|
|
590
683
|
# network must not be able to stall every other board write on this machine.
|
|
591
684
|
with _hold(repo):
|
|
592
685
|
paths, untracked = changed(repo)
|
|
686
|
+
paths, theirs = authored(paths, before, item)
|
|
687
|
+
untracked = [p for p in untracked if p in set(paths)]
|
|
688
|
+
carried = _left_alone(theirs)
|
|
593
689
|
if not paths:
|
|
594
690
|
# Nothing to commit is usually the ordinary answer — a read, or a write
|
|
595
691
|
# that changed nothing — and says nothing. Two cases are not ordinary.
|
|
@@ -610,7 +706,7 @@ def land(repo, item: str, rows: list) -> tuple:
|
|
|
610
706
|
return False, False, (
|
|
611
707
|
"carried by a board write that committed a moment earlier — the "
|
|
612
708
|
"change is in git, under that commit's item rather than this one")
|
|
613
|
-
return False, False,
|
|
709
|
+
return False, False, carried
|
|
614
710
|
|
|
615
711
|
if untracked:
|
|
616
712
|
code, _, err = _locking(repo, "add", "--", *untracked)
|
|
@@ -626,9 +722,11 @@ def land(repo, item: str, rows: list) -> tuple:
|
|
|
626
722
|
return False, False, f"could not commit the board write: {_tail(err)}"
|
|
627
723
|
|
|
628
724
|
if not GIT.get("push"):
|
|
629
|
-
return True, False,
|
|
725
|
+
return True, False, carried
|
|
630
726
|
pushed, why = _push(repo)
|
|
631
|
-
|
|
727
|
+
# Both can be true at once: a push that did not land AND work left for somebody
|
|
728
|
+
# else. Neither is allowed to hide the other.
|
|
729
|
+
return True, pushed, SEP.join(x for x in (why, carried) if x) if (why and carried) else (why or carried)
|
|
632
730
|
|
|
633
731
|
|
|
634
732
|
def _push(repo) -> tuple:
|
package/harness/test_work.py
CHANGED
|
@@ -3832,6 +3832,91 @@ def _board_write(repo, item, rows):
|
|
|
3832
3832
|
return git.land(repo, item, rows)
|
|
3833
3833
|
|
|
3834
3834
|
|
|
3835
|
+
def test_a_write_does_not_commit_prose_another_session_left_in_the_tree():
|
|
3836
|
+
# The defect, reproduced: two sessions in one checkout, each with an uncommitted
|
|
3837
|
+
# edit, and one of them runs a board command. The pathspec used to take the whole
|
|
3838
|
+
# of `work/`, so the peer's prose landed inside this commit — stamped with this
|
|
3839
|
+
# session and this item, which are both wrong, and both are read downstream to
|
|
3840
|
+
# answer who is on what.
|
|
3841
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3842
|
+
try:
|
|
3843
|
+
repo = _git_repo(tmp, push=False)
|
|
3844
|
+
versions = repo / "work" / "versions" / "01-a-cut" / "an-epic"
|
|
3845
|
+
mine = versions / "in-progress" / "mine"
|
|
3846
|
+
theirs = versions / "in-progress" / "theirs"
|
|
3847
|
+
for folder in (mine, theirs):
|
|
3848
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
3849
|
+
(folder / "task.md").write_text("# a brief\n")
|
|
3850
|
+
_git(repo, "add", "-A")
|
|
3851
|
+
_git(repo, "commit", "-qm", "two items")
|
|
3852
|
+
|
|
3853
|
+
# Both sessions type prose. Neither has committed; only one runs a command.
|
|
3854
|
+
(mine / "task.md").write_text("# a brief\n\nwhat I wrote.\n")
|
|
3855
|
+
(theirs / "task.md").write_text("# a brief\n\nwhat THEY wrote.\n")
|
|
3856
|
+
|
|
3857
|
+
before = git.dirty(repo)
|
|
3858
|
+
committed, _, note = git.land(
|
|
3859
|
+
repo, "mine", [{"event": "moved", "name": "mine", "to": "complete"}],
|
|
3860
|
+
before)
|
|
3861
|
+
assert committed, "the board write must still land"
|
|
3862
|
+
|
|
3863
|
+
body = _git(repo, "show", "--stat", "--format=%B", "HEAD").stdout
|
|
3864
|
+
assert "mine/task.md" in body, body
|
|
3865
|
+
# The whole point: their file is not in this commit, and is still dirty.
|
|
3866
|
+
assert "theirs/task.md" not in body, body
|
|
3867
|
+
assert "theirs/task.md" in _git(repo, "status", "--porcelain").stdout
|
|
3868
|
+
|
|
3869
|
+
# And the session is told, by name, so it resolves it in this turn rather
|
|
3870
|
+
# than discovering it by reading a trailer later.
|
|
3871
|
+
assert "theirs/task.md" in note, note
|
|
3872
|
+
assert "theirs" in note, note
|
|
3873
|
+
finally:
|
|
3874
|
+
events._PENDING.clear()
|
|
3875
|
+
config.apply(config.DEFAULTS)
|
|
3876
|
+
|
|
3877
|
+
|
|
3878
|
+
def test_a_write_still_commits_prose_this_session_typed_into_its_own_brief():
|
|
3879
|
+
# The other half, and the reason this is not just a narrower pathspec: a session
|
|
3880
|
+
# hand-edits the brief of the item it holds and then files it. That prose was
|
|
3881
|
+
# authored here, whenever it was typed, and a commit that left it behind would
|
|
3882
|
+
# recreate the hole the sweep was built to close — work in no commit at all.
|
|
3883
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3884
|
+
try:
|
|
3885
|
+
repo = _git_repo(tmp, push=False)
|
|
3886
|
+
folder = repo / "work" / "versions" / "01-a-cut" / "an-epic" / "in-progress" / "mine"
|
|
3887
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
3888
|
+
(folder / "task.md").write_text("# a brief\n")
|
|
3889
|
+
_git(repo, "add", "-A")
|
|
3890
|
+
_git(repo, "commit", "-qm", "an item")
|
|
3891
|
+
|
|
3892
|
+
# Typed BEFORE the command runs — so it is in `before`, and only the
|
|
3893
|
+
# item it belongs to makes it this session's.
|
|
3894
|
+
(folder / "task.md").write_text("# a brief\n\nwhat I wrote by hand.\n")
|
|
3895
|
+
before = git.dirty(repo)
|
|
3896
|
+
|
|
3897
|
+
committed, _, note = git.land(
|
|
3898
|
+
repo, "mine", [{"event": "moved", "name": "mine", "to": "complete"}],
|
|
3899
|
+
before)
|
|
3900
|
+
assert committed
|
|
3901
|
+
assert "mine/task.md" in _git(repo, "show", "--stat", "--format=%B", "HEAD").stdout
|
|
3902
|
+
assert not _git(repo, "status", "--porcelain").stdout.strip(), "nothing left behind"
|
|
3903
|
+
assert note == "", note
|
|
3904
|
+
finally:
|
|
3905
|
+
events._PENDING.clear()
|
|
3906
|
+
config.apply(config.DEFAULTS)
|
|
3907
|
+
|
|
3908
|
+
|
|
3909
|
+
def test_a_path_says_which_item_it_belongs_to_and_governance_says_none():
|
|
3910
|
+
# What decides authorship, so it is worth pinning: a task file answers its item,
|
|
3911
|
+
# and a governance doc answers nothing rather than being claimed by whoever holds
|
|
3912
|
+
# an item at the time.
|
|
3913
|
+
assert git.item_of("work/versions/01-a-cut/an-epic/in-progress/alpha/task.md") == "alpha"
|
|
3914
|
+
assert git.item_of("work/versions/01-a-cut/an-epic/queue/beta/handoff.md") == "beta"
|
|
3915
|
+
assert git.item_of("work/backlog/an-epic/gamma/task.md") == "gamma"
|
|
3916
|
+
assert git.item_of("work/architecture/data.md") == ""
|
|
3917
|
+
assert git.item_of("work/versions/01-a-cut/an-epic/epic.md") == ""
|
|
3918
|
+
|
|
3919
|
+
|
|
3835
3920
|
def test_git_is_off_by_default_so_a_shared_consumer_is_untouched():
|
|
3836
3921
|
# The harness is shared with repos that never asked for any of this. A default
|
|
3837
3922
|
# that committed on their behalf would be the same class of surprise as a hook
|
|
@@ -5008,6 +5093,26 @@ def test_a_write_aimed_at_another_branch_is_refused():
|
|
|
5008
5093
|
raise AssertionError("a write with --branch must be refused")
|
|
5009
5094
|
|
|
5010
5095
|
|
|
5096
|
+
def test_verify_takes_the_name_the_way_every_other_verb_does():
|
|
5097
|
+
# `move <name>`, `observed <task>`, `path <name>` all take a bare word, so that is
|
|
5098
|
+
# what people type for verify — and it read only `--task`. The positional landed
|
|
5099
|
+
# in `pos`, nothing looked at it, and nine gates ran against `""` for minutes and
|
|
5100
|
+
# recorded a result no item could claim. Observed live in `work/.verify` on
|
|
5101
|
+
# 2026-09-12 while a real run was in flight.
|
|
5102
|
+
seen = {}
|
|
5103
|
+
real = entry.cmd_verify
|
|
5104
|
+
try:
|
|
5105
|
+
entry.cmd_verify = lambda flags: seen.update(flags) or 0
|
|
5106
|
+
entry.dispatch("verify", ["an-item"], {}, {})
|
|
5107
|
+
assert seen.get("task") == "an-item", seen
|
|
5108
|
+
# An explicit flag still wins, so nothing changes for the tool surface.
|
|
5109
|
+
seen.clear()
|
|
5110
|
+
entry.dispatch("verify", ["positional"], {"task": "explicit"}, {})
|
|
5111
|
+
assert seen.get("task") == "explicit", seen
|
|
5112
|
+
finally:
|
|
5113
|
+
entry.cmd_verify = real
|
|
5114
|
+
|
|
5115
|
+
|
|
5011
5116
|
def test_links_is_refused_a_branch_because_fix_writes_this_one():
|
|
5012
5117
|
# It reports far more often than it fixes, which is what made listing it as a read
|
|
5013
5118
|
# look right. `--fix` writes, and it writes to the tree that is CHECKED OUT — so
|
package/harness/work.py
CHANGED
|
@@ -297,6 +297,10 @@ def main() -> int:
|
|
|
297
297
|
root = locate_work_root()[0]
|
|
298
298
|
repo = root.parent if root else _project_root(flags)
|
|
299
299
|
_say(git.refresh(repo))
|
|
300
|
+
# What was already uncommitted before this command ran. The commit afterwards
|
|
301
|
+
# takes what it wrote and leaves what it found, so a peer's half-written prose
|
|
302
|
+
# is no longer committed under this session's name and this command's item.
|
|
303
|
+
before = git.dirty(repo)
|
|
300
304
|
try:
|
|
301
305
|
return dispatch(cmd, pos, flags, cfg)
|
|
302
306
|
finally:
|
|
@@ -310,7 +314,7 @@ def main() -> int:
|
|
|
310
314
|
# with neither names NO item, rather than putting a command name where four
|
|
311
315
|
# other slices expect an item.
|
|
312
316
|
item = rows[0]["name"] if rows else (pos[0] if pos else "")
|
|
313
|
-
committed, pushed, note = git.land(repo, item, rows)
|
|
317
|
+
committed, pushed, note = git.land(repo, item, rows, before)
|
|
314
318
|
if committed:
|
|
315
319
|
where = f" and pushed to {git.GIT['remote']}" if pushed else ""
|
|
316
320
|
sys.stdout.flush()
|
|
@@ -555,6 +559,13 @@ def dispatch(cmd, pos, flags, cfg) -> int:
|
|
|
555
559
|
if cmd == "needs":
|
|
556
560
|
return cmd_needs(flags)
|
|
557
561
|
if cmd == "verify":
|
|
562
|
+
# A bare name is what the rest of this CLI takes — `move <name>`,
|
|
563
|
+
# `observed <task>`, `path <name>` — so it is what people type here too, and
|
|
564
|
+
# this read only `--task`. The positional went into `pos` and nothing looked
|
|
565
|
+
# at it: nine gates ran against an empty task name, for minutes, and recorded
|
|
566
|
+
# a result no item could claim. That reports identically to never having run.
|
|
567
|
+
if pos and not flags.get("task"):
|
|
568
|
+
flags = {**flags, "task": pos[0]}
|
|
558
569
|
return cmd_verify(flags)
|
|
559
570
|
if cmd == "observed":
|
|
560
571
|
if not pos:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.120",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"@jarvis/board": "0.1.0",
|
|
62
62
|
"@jarvis/data": "0.1.0",
|
|
63
63
|
"@jarvis/errors": "1.0.0",
|
|
64
|
-
"@jarvis/rpc": "1.0.0",
|
|
65
64
|
"@jarvis/logger": "1.0.0",
|
|
65
|
+
"@jarvis/rpc": "1.0.0",
|
|
66
66
|
"@jarvis/types": "1.0.0",
|
|
67
67
|
"@jarvis/typescript-config": "1.0.0",
|
|
68
68
|
"@jarvis/ui": "0.1.0",
|