@appchy/jarvis 0.1.86 → 0.1.87

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 = "5acb5ea";
10117
+ var SHA = "bf7fe32";
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" : ""}`;
@@ -99,7 +99,7 @@ def cmd_coverage(args) -> int:
99
99
  # had already looked at as behaviour nobody had written.
100
100
  rows, totals = [], {"declared": 0, "built": 0, "provable": 0, "proven": 0,
101
101
  "failed": 0, "todo": 0, "gap": 0, "ahead": 0,
102
- "unlevelled": 0}
102
+ "unlevelled": 0, "eyes": 0}
103
103
  mismatched = []
104
104
 
105
105
  for md in sorted(scan_features(root)):
@@ -124,14 +124,27 @@ def cmd_coverage(args) -> int:
124
124
  # number that can only go up by deleting the promise. It comes out of the
125
125
  # ratio and is reported beside it, exactly as the unbuilt roadmap is.
126
126
  eyes = {ac for ac in built if levels[ac][0] == "eyes-on"}
127
+ # A criterion that declares NO level is not run-provable either — it is
128
+ # UNKNOWN, and the two are not the same thing. It used to fall through to
129
+ # the denominator, so an unannotated promise was silently assumed to be one
130
+ # a run could settle, and the ratio quietly counted an unanswered question
131
+ # as a question with an answer. Whichever way that assumption goes it is a
132
+ # guess, and a guess inside the one number this repo is judged by is worth
133
+ # more than the tidiness of a default. It comes out and is NAMED, the way
134
+ # every other set-aside here is, so the number that remains is only what
135
+ # somebody actually declared could be settled by a run.
136
+ unknown = {ac for ac in built if levels[ac][0] is None}
127
137
  # The two ways the spec and the evidence disagree, and they point opposite
128
138
  # ways. `gap` is a promise claimed with nothing to show. `ahead` is a
129
139
  # promise a passing test already keeps while the spec still says nobody
130
140
  # built it — the doc trailing the code, which no report used to count.
131
- gap = built - passed - failed - todo - eyes
141
+ # An `unknown` is neither: until it says what would settle it, there is no
142
+ # bar to find it short of, and reporting it twice buries the one fix that
143
+ # unblocks it.
144
+ gap = built - passed - failed - todo - eyes - unknown
132
145
  ahead = passed - built
133
- provable = built - eyes
134
- proven = (built & passed) - eyes
146
+ provable = built - eyes - unknown
147
+ proven = (built & passed) - eyes - unknown
135
148
  for ac in sorted(declared):
136
149
  level = levels[ac][0]
137
150
  complaint = _wrong_level(level, runners.get(f"{name}/{ac}", set())) if level else None
@@ -146,7 +159,15 @@ def cmd_coverage(args) -> int:
146
159
  totals["todo"] += len(built & todo)
147
160
  totals["gap"] += len(gap)
148
161
  totals["ahead"] += len(ahead)
149
- totals["unlevelled"] += sum(1 for ac in declared if levels[ac][0] is None)
162
+ # BUILT ones only. A level on behaviour nobody has written is a prediction,
163
+ # which is why `lint` exempts the unbuilt too — counting them here would
164
+ # report a number nobody can act on beside one they can.
165
+ totals["unlevelled"] += len(unknown)
166
+ # COUNTED, not derived. It used to be read back as `built - provable`,
167
+ # which held while `eyes` was the only thing coming out of the ratio and
168
+ # silently absorbed the unlevelled the moment they came out too — reporting
169
+ # "settled by eyes" over criteria nobody had looked at.
170
+ totals["eyes"] += len(eyes)
150
171
  # The column is the ratio's own denominator, so it counts what the ratio
151
172
  # divides by and not everything ticked.
152
173
  rows.append((name, len(provable), len(proven), len(eyes),
@@ -193,10 +214,20 @@ def cmd_coverage(args) -> int:
193
214
  # Every count the tail reports is DERIVED from the three the loop kept, because
194
215
  # a total that can be worked out and is stored anyway is a total that can
195
216
  # disagree with the ones it was worked out from.
196
- eyes, unbuilt = t["built"] - t["provable"], t["declared"] - t["built"]
217
+ eyes, unbuilt = t["eyes"], t["declared"] - t["built"]
197
218
  rate = f"{100 * t['proven'] // t['provable']}%" if t["provable"] else "—"
198
- print(f"\n BUILT COVERAGE {t['proven']}/{t['provable']} = {rate} "
199
- f"— of the RUN-PROVABLE promises this app keeps, how many a run proves\n"
219
+ # THE CAVEAT RIDES THE HEADLINE, never a paragraph below it. Criteria with no
220
+ # level come out of both halves of this ratio, so every one of them makes the
221
+ # number look BETTER — and a figure that improves when somebody fails to
222
+ # annotate is one an unattended run can raise by doing nothing. It cannot be
223
+ # fixed by putting them back: in counts them as run-provable, out counts them
224
+ # as impossible, and both are guesses at the question the level exists to
225
+ # answer. So the ratio stays over what is actually known and refuses to be read
226
+ # alone while anything is unknown.
227
+ incomplete = (f" · INCOMPLETE: {t['unlevelled']} unlevelled, in neither half"
228
+ if t["unlevelled"] else "")
229
+ print(f"\n BUILT COVERAGE {t['proven']}/{t['provable']} = {rate}{incomplete}\n"
230
+ f" — of the RUN-PROVABLE promises this app keeps, how many a run proves\n"
200
231
  f" {t['gap']} claimed with nothing to show · {t['todo']} declared-but-unwritten · "
201
232
  f"{t['failed']} failing\n")
202
233
  print(f" Settled by eyes, not by a run: {eyes}. A look is a level, not an\n"
@@ -222,7 +253,11 @@ def cmd_coverage(args) -> int:
222
253
  print(f" {complaint}")
223
254
  print()
224
255
  if t["unlevelled"]:
225
- print(f" {t['unlevelled']} criteria declare no level, so nothing can check the test that\n"
226
- " claims them and they count as run-provable by default, which for a\n"
227
- " look is the wrong default.\n")
256
+ print(f" {t['unlevelled']} BUILT criterion(s) declare no level, so nothing can check the\n"
257
+ " test that claims them. They are OUT of the ratio above rather than\n"
258
+ " counted as run-provable: until a criterion says what could settle it,\n"
259
+ " neither answer is knowable, and a guess belongs outside the number.\n"
260
+ " Annotate each with one of e2e / integration / unit / eyes-on to bring\n"
261
+ " it back in — and a task may not complete while a criterion it covers\n"
262
+ " is still unlevelled.\n")
228
263
  return 0
@@ -33,7 +33,8 @@ from pathlib import Path
33
33
 
34
34
  from .tree import die, find_work_root
35
35
  from .frontmatter import as_list, parse_frontmatter, rewrite_file, split_frontmatter
36
- from .model import locate, missing
36
+ from .lint import AC_LEVELS, feature_ac_levels
37
+ from .model import locate, locate_feature, missing
37
38
  from .shard import _load_run
38
39
  from . import events, git, peers
39
40
  from .autonomy import tier_of
@@ -510,6 +511,7 @@ def cmd_verify(args) -> int:
510
511
  _print_report(results, sha)
511
512
  if name:
512
513
  print(f" recorded on '{name}'")
514
+ _warn_unlevelled(root, name)
513
515
  return 0 if ok else 1
514
516
 
515
517
 
@@ -565,6 +567,32 @@ def _last_verify(task) -> tuple:
565
567
  return passed, sha, when
566
568
 
567
569
 
570
+ def _warn_unlevelled(root, name: str) -> None:
571
+ """Say at VERIFY what will REFUSE at complete — the same fact, twice, early.
572
+
573
+ Warning here and refusing there is deliberate and is not the warn-instead-of-
574
+ refuse pattern the module docstring argues against: nothing is decided on this
575
+ warning, and skipping it changes no outcome. It exists because the refusal is
576
+ cheap to satisfy and expensive to meet for the first time at the finish line —
577
+ an unattended run that learns at `complete` has already spent the work, while
578
+ one that learns at `verify` can annotate the spec inside the same shift.
579
+ """
580
+ task = locate(root, name)
581
+ if not task or not task.covers:
582
+ return
583
+ feature = (task.owner or "").split("/")[-1]
584
+ md = locate_feature(root, feature)
585
+ if not md:
586
+ return
587
+ levels = feature_ac_levels(md.read_text())
588
+ unlevelled = [ac for ac in task.covers
589
+ if ac in levels and levels[ac][0] is None]
590
+ if unlevelled:
591
+ print(f"\n WARN {', '.join(unlevelled)} declare no level — `complete` will "
592
+ f"refuse\n until each says what could settle it. Annotate them in "
593
+ f"`product/{feature}.md`\n with one of {', '.join(AC_LEVELS)}.")
594
+
595
+
568
596
  def gate(root, task, accept: str = "", owner: str = "") -> list:
569
597
  """Every reason this task may NOT be called complete. Empty means it may.
570
598
 
@@ -652,8 +680,28 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
652
680
  proven = _load_run(root.parent).proven
653
681
  seen = _observed_acs(task)
654
682
  feature = (task.owner or "").split("/")[-1]
683
+ # WHAT COULD SETTLE IT, before whether anything did. A criterion with no
684
+ # declared level is not a criterion a run can be judged against: `coverage`
685
+ # cannot say whether a passing test is the right KIND of evidence for it,
686
+ # and `_wrong_level` — the check that catches a unit test claiming what only
687
+ # a person can see — has nothing to compare. So the two questions are asked
688
+ # in order, and the second is not worth asking first: "no evidence" on an
689
+ # unlevelled criterion is unanswerable rather than false, and reporting it
690
+ # as a gap sends somebody looking for a test when the missing thing is the
691
+ # bar. Scoped to what this task COVERS, never the whole feature file: a task
692
+ # is answerable for the promises it took, and refusing on a neighbour's
693
+ # would make the board incompletable by anyone but the last person to touch
694
+ # the spec.
695
+ levels = feature_ac_levels(md.read_text()) if (
696
+ md := locate_feature(root, feature)) else {}
655
697
  for ac in task.covers:
656
698
  cid = f"{feature}/{ac}" if feature else ac
699
+ if ac in levels and levels[ac][0] is None:
700
+ reasons.append(
701
+ f"{ac} declares no level, so nothing can check the test that "
702
+ f"claims it — annotate it in `product/{feature}.md` with one of "
703
+ f"{', '.join(AC_LEVELS)}")
704
+ continue
657
705
  if proven.get(cid) == "passed" or ac in seen:
658
706
  continue
659
707
  reasons.append(f"{ac} has no evidence — no passing coverage binding "
@@ -41,8 +41,8 @@ class Run(NamedTuple):
41
41
  runners: dict
42
42
  #: The shard filenames actually read.
43
43
  shards: list
44
- #: Source paths named by a shard that is no longer in the tree. Their claims
45
- #: are not in `proven`.
44
+ #: Source paths from a shard NONE of whose sources are still in the tree.
45
+ #: Their claims are not in `proven`.
46
46
  stale: list
47
47
  #: Shards that never say what they ran. Their claims ARE in `proven` — there
48
48
  #: is nothing to check them against — and they are named so a reader knows it.
@@ -62,7 +62,7 @@ def _load_run(repo: Path) -> Run:
62
62
  `playwright.json`. A criterion bound at both levels reported whichever
63
63
  runner sorted last, which is the one thing this file exists not to do.
64
64
 
65
- A SHARD OUTLIVES THE TEST THAT WROTE IT, so one whose `source` is no longer
65
+ A SHARD OUTLIVES THE TESTS THAT WROTE IT, so one none of whose `sources` are
66
66
  in the tree is set aside rather than read. It is a result about code this
67
67
  checkout does not have, and nothing rewrites it: a renamed or deleted spec
68
68
  would otherwise go on claiming `passed` forever, which is the same fault a
@@ -86,14 +86,26 @@ def _load_run(repo: Path) -> Run:
86
86
  data = json.loads(p.read_text())
87
87
  except Exception:
88
88
  continue
89
- source = data.get("source")
90
- if not isinstance(source, str) or source == "":
91
- # COUNTED, AND NAMED. A runner that does not write a source cannot be
89
+ # A LIST, because one shard is one RUN and a run covers many files. It was
90
+ # a single `source` string, which no writer ever managed to fill: a vitest
91
+ # workspace has no one file to name, so every shard fell through to
92
+ # `unattributed` and the check has never once run. A field only a
93
+ # single-file runner could satisfy is a field that reads as absent for
94
+ # everybody else.
95
+ sources = data.get("sources")
96
+ sources = [s for s in sources if isinstance(s, str) and s] if isinstance(sources, list) else []
97
+ if not sources:
98
+ # COUNTED, AND NAMED. A runner that does not write its sources cannot be
92
99
  # checked, and dropping it would throw away real evidence to punish a
93
100
  # writer. Saying so is what lets somebody fix the writer.
94
101
  read.unattributed.append(p.name)
95
- elif not (repo / source).exists():
96
- read.stale.append(source)
102
+ elif not any((repo / s).exists() for s in sources):
103
+ # EVERY source gone, not merely one. A run spanning ten files where one
104
+ # was deleted still proved the other nine, and setting the whole shard
105
+ # aside would discard them to punish a rename. Only when nothing it ran
106
+ # is still in the tree is the shard a result about code this checkout
107
+ # does not have.
108
+ read.stale.extend(sources)
97
109
  continue
98
110
  read.shards.append(p.name)
99
111
  runner = data.get("runner") or "unknown"
@@ -27,7 +27,7 @@ from harness.ids import LEDGER as L # noqa: E402 — fixtures render in the rep
27
27
  from harness import (align, architecture, autonomy, branches, config, coverage, epic, # noqa: E402
28
28
  events, extend, frontmatter, gate, generate, git, ids, kickoff,
29
29
  lint, model, peers, registry, report, safety, scaffold,
30
- shift, task, tree, version)
30
+ shard, shift, task, tree, version)
31
31
 
32
32
  # `parse_argv` is the ENTRY's own concern, so it is loaded from work.py by path
33
33
  # rather than re-homed into a module just to make a test tidier.
@@ -1119,11 +1119,15 @@ def _shards(tmp: str, **byname: dict) -> Path:
1119
1119
 
1120
1120
  def _ran(repo: Path, name: str, source: str, covered: dict) -> Path:
1121
1121
  """One shard that names the file it ran. Returns that file's path so a test
1122
- can create it, or rename it away."""
1122
+ can create it, or rename it away.
1123
+
1124
+ `sources` is a LIST on the shard — one run covers many files — and this helper
1125
+ writes a single-entry one, which is the case these tests exercise: with one
1126
+ source, "none of them survive" and "that one is gone" are the same event."""
1123
1127
  d = repo / ".work" / "coverage"
1124
1128
  d.mkdir(parents=True, exist_ok=True)
1125
1129
  (d / f"{name}.json").write_text(
1126
- json.dumps({"runner": "vitest", "source": source, "covered": covered}))
1130
+ json.dumps({"runner": "vitest", "sources": [source], "covered": covered}))
1127
1131
  return repo / source
1128
1132
 
1129
1133
 
@@ -3263,6 +3267,59 @@ def test_a_promised_criterion_needs_evidence_a_run_or_a_person_produced():
3263
3267
  assert not any("AC-01" in r for r in reasons), reasons
3264
3268
 
3265
3269
 
3270
+ def test_a_criterion_with_no_level_is_refused_before_it_is_asked_for_evidence():
3271
+ # The two questions in order. "No evidence" on a criterion that never said what
3272
+ # could settle it is unanswerable rather than false, and reporting it as a gap
3273
+ # sends somebody hunting for a test when the missing thing is the bar.
3274
+ with tempfile.TemporaryDirectory() as tmp:
3275
+ v = _tree(tmp)
3276
+ e = _epic(v, "an-epic")
3277
+ _task(e / "in-progress", "alpha",
3278
+ fm="priority: P1\ntier: 1\nowner: lesson\ncovers: [AC-01]\n",
3279
+ body="# T\n")
3280
+ with _work_dir(tmp) as root:
3281
+ spec = root / "product" / "lesson.md"
3282
+ spec.parent.mkdir(parents=True, exist_ok=True)
3283
+ spec.write_text("---\ntype: feature\nstate: building\n---\n\n"
3284
+ "# Lesson\n\n- [x] AC-01: the card renders\n")
3285
+ reasons = gate.gate(root, model.locate(root, "alpha"))
3286
+ assert any("AC-01 declares no level" in r for r in reasons), reasons
3287
+ assert not any("has no evidence" in r for r in reasons), reasons
3288
+
3289
+ # Annotated, and the gate moves on to the question it was blocking.
3290
+ spec.write_text("---\ntype: feature\nstate: building\n---\n\n"
3291
+ "# Lesson\n\n- [x] AC-01 (unit): the card renders\n")
3292
+ reasons = gate.gate(root, model.locate(root, "alpha"))
3293
+ assert not any("declares no level" in r for r in reasons), reasons
3294
+ assert any("AC-01 has no evidence" in r for r in reasons), reasons
3295
+
3296
+
3297
+ def test_a_run_still_counts_when_one_of_its_files_was_deleted():
3298
+ # `sources` is a LIST because one run covers many files. Setting the whole
3299
+ # shard aside because a single spec was renamed would discard the other nine
3300
+ # files' real results to punish the rename.
3301
+ with tempfile.TemporaryDirectory() as tmp:
3302
+ repo = Path(tmp)
3303
+ d = repo / ".work" / "coverage"
3304
+ d.mkdir(parents=True)
3305
+ (repo / "kept.test.ts").write_text("//")
3306
+ (d / "vitest-pkg.json").write_text(json.dumps({
3307
+ "runner": "vitest",
3308
+ "sources": ["kept.test.ts", "gone.test.ts"],
3309
+ "covered": {"lesson/AC-01": {"status": "passed"}},
3310
+ }))
3311
+ read = shard._load_run(repo)
3312
+ assert read.proven.get("lesson/AC-01") == "passed", read.proven
3313
+ assert read.unattributed == [], read.unattributed
3314
+
3315
+ # Every source gone is a different event: the result is about code this
3316
+ # checkout does not have, so it is set aside rather than believed.
3317
+ (repo / "kept.test.ts").unlink()
3318
+ gone = shard._load_run(repo)
3319
+ assert gone.proven == {}, gone.proven
3320
+ assert sorted(gone.stale) == ["gone.test.ts", "kept.test.ts"], gone.stale
3321
+
3322
+
3266
3323
  def test_the_event_log_is_history_and_nothing_reads_it_for_state():
3267
3324
  # The line that must not blur. If a check ever needs a fact from the log, that
3268
3325
  # fact belongs in the tree instead — otherwise this becomes the central ledger
@@ -6382,7 +6439,7 @@ def _coverage_fixture(tmp: str, body: str, shard: dict = None) -> Path:
6382
6439
  d = root.parent / ".work" / "coverage"
6383
6440
  d.mkdir(parents=True)
6384
6441
  (d / "vitest.json").write_text(json.dumps(
6385
- {"source": "pkg/x.test.ts", "runner": "vitest", "covered": shard}))
6442
+ {"sources": ["pkg/x.test.ts"], "runner": "vitest", "covered": shard}))
6386
6443
  return root
6387
6444
 
6388
6445
 
@@ -6719,7 +6776,7 @@ def test_a_repo_that_moves_its_coverage_shard_is_actually_read_there():
6719
6776
  elsewhere = root.parent / "build" / "evidence"
6720
6777
  elsewhere.mkdir(parents=True)
6721
6778
  (elsewhere / "vitest.json").write_text(json.dumps(
6722
- {"source": "pkg/x.test.ts", "runner": "vitest",
6779
+ {"sources": ["pkg/x.test.ts"], "runner": "vitest",
6723
6780
  "covered": {"viewer/AC-01": {"status": "passed"}}}))
6724
6781
 
6725
6782
  config.apply({**config.DEFAULTS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.86",
3
+ "version": "0.1.87",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -56,17 +56,17 @@
56
56
  "tsup": "^8.5.1",
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^2.1.0",
59
- "@jarvis/agents": "1.0.0",
59
+ "@jarvis/anthropic": "1.0.0",
60
+ "@jarvis/board": "0.1.0",
60
61
  "@jarvis/data": "0.1.0",
61
62
  "@jarvis/logger": "1.0.0",
63
+ "@jarvis/errors": "1.0.0",
62
64
  "@jarvis/rpc": "1.0.0",
63
- "@jarvis/anthropic": "1.0.0",
64
- "@jarvis/typescript-config": "1.0.0",
65
65
  "@jarvis/types": "1.0.0",
66
+ "@jarvis/typescript-config": "1.0.0",
66
67
  "@jarvis/ui": "0.1.0",
67
68
  "@jarvis/vitest-config": "1.0.0",
68
- "@jarvis/errors": "1.0.0",
69
- "@jarvis/board": "0.1.0"
69
+ "@jarvis/agents": "1.0.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",