@appchy/jarvis 0.1.85 → 0.1.86

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
@@ -10114,7 +10114,7 @@ import { createRequire as createRequire2 } from "module";
10114
10114
  var _require = createRequire2(import.meta.url);
10115
10115
  var VERSION2 = _require("../package.json").version ?? "0.0.0";
10116
10116
  var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
10117
- var SHA = "f9f9db3";
10117
+ var SHA = "5acb5ea";
10118
10118
  var BUILT = "2026-09-11";
10119
10119
  var BUILD = SHA ?? "source";
10120
10120
  var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
@@ -548,12 +548,11 @@ def _validate(cfg: dict) -> None:
548
548
  # would commit files nobody asked about. Refused at load, where it is one
549
549
  # message, rather than at the commit, where it is a surprise in somebody's
550
550
  # history.
551
- if Path(p).is_absolute() or ".." in Path(p).parts:
551
+ if _escapes(p):
552
552
  raise ConfigError(f"git.paths entry {p!r} must be repo-relative and stay "
553
553
  f"inside the repo")
554
554
  shard = cfg["coverage"]["shard"]
555
- if (not isinstance(shard, str) or not shard.strip()
556
- or Path(shard).is_absolute() or ".." in Path(shard).parts):
555
+ if not isinstance(shard, str) or not shard.strip() or _escapes(shard):
557
556
  raise ConfigError("coverage.shard must be a repo-relative directory where "
558
557
  "test runners drop their evidence, e.g. \".work/coverage\"")
559
558
  at = cfg["wrap"]["at_percent"]
@@ -587,6 +586,19 @@ def _validate(cfg: dict) -> None:
587
586
  "session up, or null to remind without naming one")
588
587
 
589
588
 
589
+ def _escapes(value: str) -> bool:
590
+ """Could this path reach outside the repo it is written in?
591
+
592
+ Every path a repo names in its config is joined onto the repo root, so an
593
+ absolute one silently becomes the whole answer and a `..` walks out. One reader
594
+ for the question, because the two places that ask it — what a board write
595
+ commits, and where the runners drop their evidence — would otherwise each carry
596
+ their own copy of the same two conditions.
597
+ """
598
+ p = Path(value)
599
+ return p.is_absolute() or ".." in p.parts
600
+
601
+
590
602
  def load(repo: Path) -> dict:
591
603
  """The merged config for a repo. Missing file → the defaults, which is a
592
604
  working configuration and not an error: a fresh install must run."""
@@ -1425,7 +1437,13 @@ def _cmd_config_write(args) -> int:
1425
1437
  # and refusing to remove it would leave the only tool that can fix it
1426
1438
  # refusing on the grounds that the thing being fixed is broken.
1427
1439
  parts = [seg for seg in dotted.split(".") if seg]
1428
- if args["_verb"] != "unset" or not _present(raw, parts):
1440
+ # `not parts` is the empty key, and it is the one thing this branch must not
1441
+ # take for a repair: `_present` answers True for a path of no segments — the
1442
+ # file trivially "contains" nothing — so an empty name walked straight past
1443
+ # the check and into `parts[-1]`, which is an IndexError traceback on the
1444
+ # command an installer shells out to. A key that names nothing is refused
1445
+ # with the message the loader already wrote for it.
1446
+ if args["_verb"] != "unset" or not parts or not _present(raw, parts):
1429
1447
  raise
1430
1448
  unknown = True
1431
1449
 
@@ -90,8 +90,15 @@ def cmd_coverage(args) -> int:
90
90
  return 0
91
91
 
92
92
  only = args.get("feature")
93
- rows, totals = [], {"declared": 0, "built": 0, "proven": 0, "failed": 0,
94
- "todo": 0, "gap": 0, "ahead": 0, "eyes": 0, "unbuilt": 0,
93
+ # `built` is every criterion TICKED, and `provable` is the run-provable part of
94
+ # it built minus the ones only a person can settle. They were one number under
95
+ # the name `built`, holding the value of `provable`, and that single misnomer is
96
+ # what produced both of the report's disagreements: the table's `built` column
97
+ # printed one of them while the ratio underneath divided by the other, and the
98
+ # roadmap line read `declared - built` and so counted every criterion a person
99
+ # had already looked at as behaviour nobody had written.
100
+ rows, totals = [], {"declared": 0, "built": 0, "provable": 0, "proven": 0,
101
+ "failed": 0, "todo": 0, "gap": 0, "ahead": 0,
95
102
  "unlevelled": 0}
96
103
  mismatched = []
97
104
 
@@ -125,7 +132,6 @@ def cmd_coverage(args) -> int:
125
132
  ahead = passed - built
126
133
  provable = built - eyes
127
134
  proven = (built & passed) - eyes
128
-
129
135
  for ac in sorted(declared):
130
136
  level = levels[ac][0]
131
137
  complaint = _wrong_level(level, runners.get(f"{name}/{ac}", set())) if level else None
@@ -133,21 +139,16 @@ def cmd_coverage(args) -> int:
133
139
  mismatched.append(f"{name}/{ac}: {complaint}")
134
140
 
135
141
  totals["declared"] += len(declared)
136
- totals["built"] += len(provable)
142
+ totals["built"] += len(built)
143
+ totals["provable"] += len(provable)
137
144
  totals["proven"] += len(proven)
138
145
  totals["failed"] += len(failed)
139
146
  totals["todo"] += len(built & todo)
140
147
  totals["gap"] += len(gap)
141
148
  totals["ahead"] += len(ahead)
142
- totals["eyes"] += len(eyes)
143
- # COUNTED, not derived from `declared - built`. An eyes-on criterion is
144
- # built and comes out of the ratio, so subtracting the ratio's denominator
145
- # reported every one of them as behaviour nobody had written yet.
146
- totals["unbuilt"] += len(declared - built)
147
149
  totals["unlevelled"] += sum(1 for ac in declared if levels[ac][0] is None)
148
- # The column is the ratio's own denominator. Counting all of `built` here
149
- # while the ratio counted `provable` made the table disagree with the number
150
- # printed under it, by exactly the eyes-on criteria.
150
+ # The column is the ratio's own denominator, so it counts what the ratio
151
+ # divides by and not everything ticked.
151
152
  rows.append((name, len(provable), len(proven), len(eyes),
152
153
  len(built & todo), len(declared - built),
153
154
  sorted(failed), sorted(gap), sorted(ahead)))
@@ -189,16 +190,20 @@ def cmd_coverage(args) -> int:
189
190
  f"{unbuilt:>8} {note}")
190
191
 
191
192
  t = totals
192
- rate = f"{100 * t['proven'] // t['built']}%" if t["built"] else "—"
193
- print(f"\n BUILT COVERAGE {t['proven']}/{t['built']} = {rate} "
193
+ # Every count the tail reports is DERIVED from the three the loop kept, because
194
+ # a total that can be worked out and is stored anyway is a total that can
195
+ # disagree with the ones it was worked out from.
196
+ eyes, unbuilt = t["built"] - t["provable"], t["declared"] - t["built"]
197
+ rate = f"{100 * t['proven'] // t['provable']}%" if t["provable"] else "—"
198
+ print(f"\n BUILT COVERAGE {t['proven']}/{t['provable']} = {rate} "
194
199
  f"— of the RUN-PROVABLE promises this app keeps, how many a run proves\n"
195
200
  f" {t['gap']} claimed with nothing to show · {t['todo']} declared-but-unwritten · "
196
201
  f"{t['failed']} failing\n")
197
- print(f" Settled by eyes, not by a run: {t['eyes']}. A look is a level, not an\n"
202
+ print(f" Settled by eyes, not by a run: {eyes}. A look is a level, not an\n"
198
203
  f" excuse — no assertion is evidence about weight, colour or rhythm — so\n"
199
204
  f" these sit beside the ratio with the dated ✔ in the feature file as their\n"
200
205
  f" evidence, never inside it.\n")
201
- print(f" Not built yet: {t['unbuilt']} of {t['declared']} promises. "
206
+ print(f" Not built yet: {unbuilt} of {t['declared']} promises. "
202
207
  f"That is a roadmap, NOT a coverage hole —\n"
203
208
  f" an unticked criterion is behaviour nobody has written, so counting it\n"
204
209
  f" against coverage measures ambition rather than honesty.\n")
@@ -196,10 +196,15 @@ def cmd_epic_move(args) -> int:
196
196
  # `epic.md` removed, so an epic promoted out of one arrives with nothing to
197
197
  # write — and reading it anyway raised after the folders had already moved,
198
198
  # leaving a half-moved tree no CLI command could put back.
199
- md = dest / "epic.md"
200
- if md.is_file():
199
+ #
200
+ # The model owns that fact and is asked for it, rather than re-stat'ing the
201
+ # moved path: `release` asks the same question the same way, and two
202
+ # implementations of "does this epic have a plan doc" is one more than there
203
+ # should be. The move relocates the file rather than replacing it, so what was
204
+ # read before it is still true after.
205
+ if epic.planned:
201
206
  rewrite_file(
202
- md,
207
+ dest / "epic.md",
203
208
  lambda d: d.update({"updated": date.today().isoformat()}),
204
209
  EPIC_FM_ORDER,
205
210
  )
@@ -665,16 +665,6 @@ def _push(repo) -> tuple:
665
665
  f"`{cli()} sync` sends it when you can reach {remote}.")
666
666
 
667
667
 
668
- #: Git's own complaint, wherever it sits in the stream. It is extracted rather than
669
- #: read off the first line because the first lines are a fetch banner and a progress
670
- #: counter — and the counter carries no newline, so `Rebasing (1/1)error: could not
671
- #: apply …` arrives as ONE line with the reason buried at the end of it. Measured
672
- #: under four concurrent board writes: three refusals in a row explained themselves
673
- #: as "From /tmp/…/origin", "warning: fetch updated the current branch head.." and
674
- #: "Rebasing (1/4)." — three non-answers to the only question being asked.
675
- _COMPLAINT = re.compile(r"(?:error|fatal): (.+)")
676
-
677
-
678
668
  def _why_no_rebase(err: str) -> str:
679
669
  """Why the rebase onto the moved branch did not run, in the caller's terms.
680
670
 
@@ -693,9 +683,7 @@ def _why_no_rebase(err: str) -> str:
693
683
  "the way of rebasing onto it — nothing was moved or stashed. They "
694
684
  "may be yours or another board write that has not committed yet; "
695
685
  "commit what is yours and the next board write pushes both")
696
- m = _COMPLAINT.search(err or "")
697
- return ("the branch moved and the rebase onto it did not apply: "
698
- f"{m.group(1).strip()[:200] if m else _tail(err)}")
686
+ return f"the branch moved and the rebase onto it did not apply: {_tail(err)}"
699
687
 
700
688
 
701
689
  def _message(item: str, rows: list) -> tuple:
@@ -779,9 +767,9 @@ def _events(record: str) -> list:
779
767
  who = (trailers.get(SESSION) or [""])[0]
780
768
  host = (trailers.get(MACHINE) or [""])[0]
781
769
  out = []
782
- # How many of this kind have been handed out already, so the second `verified`
783
- # in a commit gets the second body line rather than the first's.
784
- taken: dict = {}
770
+ # One pool per kind, drawn from in order, so the second `verified` in a commit
771
+ # gets the second body line rather than the first's.
772
+ pools = {kind: iter(payloads) for kind, payloads in details.items()}
785
773
  for kind in kinds:
786
774
  e = {"ts": _utc(when), "event": kind, "name": item, "sha": sha[:12],
787
775
  "author": author}
@@ -789,11 +777,7 @@ def _events(record: str) -> list:
789
777
  e["by"] = who
790
778
  if host:
791
779
  e["machine"] = host
792
- payloads = details.get(kind) or []
793
- i = taken.get(kind, 0)
794
- if i < len(payloads):
795
- e.update(payloads[i])
796
- taken[kind] = i + 1
780
+ e.update(next(pools.get(kind, iter(())), {}))
797
781
  out.append(e)
798
782
  return out
799
783
 
@@ -924,12 +908,31 @@ def cmd_sync(args=None) -> int:
924
908
  return 0 if pushed else 1
925
909
 
926
910
 
911
+ #: Git saying what went wrong, wherever in its output that sits.
912
+ _COMPLAINT = re.compile(r"(?:error|fatal): (.+)")
913
+
914
+
927
915
  def _tail(text: str) -> str:
928
- """Git's first real complaint. The FIRST line, not the last: git follows a
929
- `fatal:` with a paragraph of advice, and the closing line of that paragraph
930
- ("…and the repository exists.") reads as gibberish on its own."""
931
- for line in (text or "").splitlines():
932
- line = line.strip()
933
- if line and not line.startswith("hint:"):
934
- return line[:200]
935
- return "no reason given"
916
+ """Git's real complaint, in the words it used.
917
+
918
+ Never the LAST line: git follows a `fatal:` with a paragraph of advice, and the
919
+ closing line of that paragraph ("…and the repository exists.") reads as gibberish
920
+ on its own. And no longer the FIRST line either, because the first line is
921
+ routinely a banner `To <url>` on a rejected push, `From <url>` on a fetch,
922
+ `Rebasing (1/4)` on a rebase — and the progress counter carries no newline, so
923
+ `Rebasing (1/1)error: could not apply …` arrives as ONE line with the reason
924
+ buried at the end of it.
925
+
926
+ Measured, both shapes: a rejected push explained itself as "To /tmp/…/o.git",
927
+ and three concurrent board writes explained their refusals as a fetch banner, a
928
+ warning and a progress counter — four non-answers to the only question being
929
+ asked. So the complaint is looked for first and the first line is the fallback,
930
+ which is what this always did and is still right when git leads with `fatal:`.
931
+ """
932
+ lines = [ln.strip() for ln in (text or "").splitlines()]
933
+ lines = [ln for ln in lines if ln and not ln.startswith("hint:")]
934
+ for line in lines:
935
+ found = _COMPLAINT.search(line)
936
+ if found:
937
+ return found.group(1).strip()[:200]
938
+ return lines[0][:200] if lines else "no reason given"
@@ -481,6 +481,15 @@ def _coverage_lint(root: Path, s: dict) -> list:
481
481
  if not product_dir.is_dir():
482
482
  return warns
483
483
 
484
+ # THE QUESTION FIRST, then the evidence for it. Only a `shipped` feature is
485
+ # checked, so a repo with none has nothing to answer and the gathering below is
486
+ # pure waste — and it is not free: this runs after every board write, and the
487
+ # filed-cut half of it grows with every release and never shrinks.
488
+ shipped = [md for md in scan_features(root)
489
+ if parse_frontmatter(md.read_text()).get("state") == "shipped"]
490
+ if not shipped:
491
+ return warns
492
+
484
493
  covered: dict = {}
485
494
  # The cuts that have LEFT the board carry evidence too — a criterion delivered
486
495
  # by a task in a shipped cut is delivered. Reading the live board alone turned
@@ -500,10 +509,8 @@ def _coverage_lint(root: Path, s: dict) -> list:
500
509
  # matched nothing and this whole rollup was dead code wearing a passing
501
510
  # test. `scan_features` is now the single owner of that glob, so the next
502
511
  # layout change cannot leave one caller behind.)
503
- for feature_md in scan_features(root):
512
+ for feature_md in shipped:
504
513
  text = feature_md.read_text()
505
- if parse_frontmatter(text).get("state") != "shipped":
506
- continue
507
514
  have = covered.get(feature_md.stem, set())
508
515
  for ac in sorted(_feature_ac_ids(text)):
509
516
  if ac not in have:
@@ -330,7 +330,7 @@
330
330
  "shard": {
331
331
  "type": "string",
332
332
  "default": ".work/coverage",
333
- "description": "Where runners drop coverage shards. Gitignored on purpose — evidence is a fresh run, not a file somebody checked in."
333
+ "description": "Where runners drop coverage shards, repo-relative. Gitignored on purpose — evidence is a fresh run, not a file somebody checked in."
334
334
  }
335
335
  }
336
336
  },
@@ -6894,6 +6894,49 @@ def test_four_board_writes_racing_a_rejected_push_lose_nothing():
6894
6894
  config.apply(config.DEFAULTS)
6895
6895
 
6896
6896
 
6897
+ def test_config_unset_with_no_key_says_so_rather_than_throwing():
6898
+ # The repair branch — `unset` may remove a key the schema does not know — took
6899
+ # the empty key for one of those, because `_present` answers True for a path of
6900
+ # no segments. It then indexed the last of zero parts, so the command an
6901
+ # installer shells out to answered with an IndexError traceback.
6902
+ with tempfile.TemporaryDirectory() as tmp:
6903
+ repo = _repo_with_config(tmp, {"ids": {"prefix": "G"}})
6904
+ for key in ("", ".", " "):
6905
+ try:
6906
+ _cli(repo, "config", "unset", key)
6907
+ assert False, f"unset {key!r} should refuse"
6908
+ except SystemExit as e:
6909
+ assert e.code, f"unset {key!r} exited 0"
6910
+ assert json.loads((repo / ".claude" / "work.config.json").read_text()) == \
6911
+ {"ids": {"prefix": "G"}}, "a refused unset changed the file"
6912
+
6913
+
6914
+ def test_asking_where_the_gates_stand_never_commits_the_board():
6915
+ # `verify` writes the result of a run it EXECUTES, and two of its four doors
6916
+ # execute nothing: `--async` starts a detached child that commits its own
6917
+ # result, `--status` only reports. Both were driving a commit that swept up
6918
+ # whatever the person had open in work/, as "docs(work): board edits" under no
6919
+ # item — seen twice on this repo's own board in one session, while polling for a
6920
+ # gate to finish, which is exactly the call a session repeats.
6921
+ with tempfile.TemporaryDirectory() as tmp:
6922
+ try:
6923
+ repo = _git_repo_cli(tmp)
6924
+ (repo / ".claude" / "work.config.json").write_text(json.dumps(
6925
+ {"git": {"commit": True, "push": False, "remote": "origin",
6926
+ "paths": ["work"]},
6927
+ "verify": {"tests": "true"}}))
6928
+ _git(repo, "add", "-A"); _git(repo, "commit", "-qm", "gates configured")
6929
+ (repo / "work" / "product" / "README.md").write_text("a hand edit\n")
6930
+ before = _git(repo, "rev-parse", "HEAD").stdout.strip()
6931
+
6932
+ _entry(repo, "verify", "--task", "alpha", "--status")
6933
+ assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before, \
6934
+ "asking where the gates stand committed the board"
6935
+ assert "work/product/README.md" in _git(repo, "status", "--porcelain").stdout
6936
+ finally:
6937
+ config.apply(config.DEFAULTS)
6938
+
6939
+
6897
6940
  if __name__ == "__main__":
6898
6941
  tests = [v for k, v in sorted(globals().items())
6899
6942
  if k.startswith("test_") and callable(v)]
package/harness/work.py CHANGED
@@ -284,7 +284,7 @@ def main() -> int:
284
284
  # person happened to have open in `work/` at that moment, filed as
285
285
  # "docs(work): board edits" under no item, at a moment nobody chose. The pull
286
286
  # was already scoped this way; now both halves ask the same question.
287
- if cmd not in git.WRITES or not git.enabled():
287
+ if not _writes(cmd, flags) or not git.enabled():
288
288
  return dispatch(cmd, pos, flags, cfg)
289
289
 
290
290
  # Pull → write → commit → push. The commit runs in a `finally`: a command that
@@ -314,6 +314,25 @@ def main() -> int:
314
314
  _say(note)
315
315
 
316
316
 
317
+ def _writes(cmd: str, flags: dict) -> bool:
318
+ """Does THIS invocation change the board?
319
+
320
+ The command name answers it for all but one. `verify` writes the result of a run
321
+ it EXECUTES, and two of its four doors execute nothing: `--async` starts a
322
+ detached child that commits its own result, and `--status` only reports. Both
323
+ write to `work/.verify`, which is gitignored, so neither has anything of its own
324
+ to land — and both were driving a commit that swept up whatever the person had
325
+ open in `work/`, filed as "docs(work): board edits" under no item. Seen twice on
326
+ this repo's own board in one session, while polling for a gate to finish, which
327
+ is exactly the call a session repeats.
328
+ """
329
+ if cmd not in git.WRITES:
330
+ return False
331
+ if cmd == "verify" and (flags.get("async") or flags.get("status")):
332
+ return False
333
+ return True
334
+
335
+
317
336
  def _say(note: str) -> None:
318
337
  """A git note the caller has to see. stderr, because it is the difference
319
338
  between a write that is safe and one that is only safe on this machine — and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.85",
3
+ "version": "0.1.86",
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/anthropic": "1.0.0",
61
- "@jarvis/board": "0.1.0",
62
60
  "@jarvis/data": "0.1.0",
63
- "@jarvis/errors": "1.0.0",
64
- "@jarvis/rpc": "1.0.0",
65
61
  "@jarvis/logger": "1.0.0",
66
- "@jarvis/types": "1.0.0",
62
+ "@jarvis/rpc": "1.0.0",
63
+ "@jarvis/anthropic": "1.0.0",
67
64
  "@jarvis/typescript-config": "1.0.0",
65
+ "@jarvis/types": "1.0.0",
68
66
  "@jarvis/ui": "0.1.0",
69
- "@jarvis/vitest-config": "1.0.0"
67
+ "@jarvis/vitest-config": "1.0.0",
68
+ "@jarvis/errors": "1.0.0",
69
+ "@jarvis/board": "0.1.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",