@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 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 = "1dfb739";
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" : ""}`;
@@ -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 changed and push it. Returns (committed, pushed, note).
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. A push that
579
- cannot land is NOT a failure of the write: the change is committed, which 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
- return True, pushed, why
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:
@@ -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.118",
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",