@appchy/jarvis 0.1.85 → 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 +1 -1
- package/harness/harness/config.py +22 -4
- package/harness/harness/coverage.py +64 -24
- package/harness/harness/epic.py +8 -3
- package/harness/harness/gate.py +49 -1
- package/harness/harness/git.py +32 -29
- package/harness/harness/lint.py +10 -3
- package/harness/harness/shard.py +20 -8
- package/harness/schema/work.config.schema.json +1 -1
- package/harness/test_work.py +105 -5
- package/harness/work.py +20 -1
- package/package.json +4 -4
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 = "
|
|
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" : ""}`;
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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,9 +90,16 @@ def cmd_coverage(args) -> int:
|
|
|
90
90
|
return 0
|
|
91
91
|
|
|
92
92
|
only = args.get("feature")
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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,
|
|
102
|
+
"unlevelled": 0, "eyes": 0}
|
|
96
103
|
mismatched = []
|
|
97
104
|
|
|
98
105
|
for md in sorted(scan_features(root)):
|
|
@@ -117,15 +124,27 @@ def cmd_coverage(args) -> int:
|
|
|
117
124
|
# number that can only go up by deleting the promise. It comes out of the
|
|
118
125
|
# ratio and is reported beside it, exactly as the unbuilt roadmap is.
|
|
119
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}
|
|
120
137
|
# The two ways the spec and the evidence disagree, and they point opposite
|
|
121
138
|
# ways. `gap` is a promise claimed with nothing to show. `ahead` is a
|
|
122
139
|
# promise a passing test already keeps while the spec still says nobody
|
|
123
140
|
# built it — the doc trailing the code, which no report used to count.
|
|
124
|
-
|
|
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
|
|
125
145
|
ahead = passed - built
|
|
126
|
-
provable = built - eyes
|
|
127
|
-
proven = (built & passed) - eyes
|
|
128
|
-
|
|
146
|
+
provable = built - eyes - unknown
|
|
147
|
+
proven = (built & passed) - eyes - unknown
|
|
129
148
|
for ac in sorted(declared):
|
|
130
149
|
level = levels[ac][0]
|
|
131
150
|
complaint = _wrong_level(level, runners.get(f"{name}/{ac}", set())) if level else None
|
|
@@ -133,21 +152,24 @@ def cmd_coverage(args) -> int:
|
|
|
133
152
|
mismatched.append(f"{name}/{ac}: {complaint}")
|
|
134
153
|
|
|
135
154
|
totals["declared"] += len(declared)
|
|
136
|
-
totals["built"] += len(
|
|
155
|
+
totals["built"] += len(built)
|
|
156
|
+
totals["provable"] += len(provable)
|
|
137
157
|
totals["proven"] += len(proven)
|
|
138
158
|
totals["failed"] += len(failed)
|
|
139
159
|
totals["todo"] += len(built & todo)
|
|
140
160
|
totals["gap"] += len(gap)
|
|
141
161
|
totals["ahead"] += len(ahead)
|
|
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.
|
|
142
170
|
totals["eyes"] += len(eyes)
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
# reported every one of them as behaviour nobody had written yet.
|
|
146
|
-
totals["unbuilt"] += len(declared - built)
|
|
147
|
-
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.
|
|
171
|
+
# The column is the ratio's own denominator, so it counts what the ratio
|
|
172
|
+
# divides by and not everything ticked.
|
|
151
173
|
rows.append((name, len(provable), len(proven), len(eyes),
|
|
152
174
|
len(built & todo), len(declared - built),
|
|
153
175
|
sorted(failed), sorted(gap), sorted(ahead)))
|
|
@@ -189,16 +211,30 @@ def cmd_coverage(args) -> int:
|
|
|
189
211
|
f"{unbuilt:>8} {note}")
|
|
190
212
|
|
|
191
213
|
t = totals
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
214
|
+
# Every count the tail reports is DERIVED from the three the loop kept, because
|
|
215
|
+
# a total that can be worked out and is stored anyway is a total that can
|
|
216
|
+
# disagree with the ones it was worked out from.
|
|
217
|
+
eyes, unbuilt = t["eyes"], t["declared"] - t["built"]
|
|
218
|
+
rate = f"{100 * t['proven'] // t['provable']}%" if t["provable"] else "—"
|
|
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"
|
|
195
231
|
f" {t['gap']} claimed with nothing to show · {t['todo']} declared-but-unwritten · "
|
|
196
232
|
f"{t['failed']} failing\n")
|
|
197
|
-
print(f" Settled by eyes, not by a run: {
|
|
233
|
+
print(f" Settled by eyes, not by a run: {eyes}. A look is a level, not an\n"
|
|
198
234
|
f" excuse — no assertion is evidence about weight, colour or rhythm — so\n"
|
|
199
235
|
f" these sit beside the ratio with the dated ✔ in the feature file as their\n"
|
|
200
236
|
f" evidence, never inside it.\n")
|
|
201
|
-
print(f" Not built yet: {
|
|
237
|
+
print(f" Not built yet: {unbuilt} of {t['declared']} promises. "
|
|
202
238
|
f"That is a roadmap, NOT a coverage hole —\n"
|
|
203
239
|
f" an unticked criterion is behaviour nobody has written, so counting it\n"
|
|
204
240
|
f" against coverage measures ambition rather than honesty.\n")
|
|
@@ -217,7 +253,11 @@ def cmd_coverage(args) -> int:
|
|
|
217
253
|
print(f" {complaint}")
|
|
218
254
|
print()
|
|
219
255
|
if t["unlevelled"]:
|
|
220
|
-
print(f" {t['unlevelled']}
|
|
221
|
-
" claims them
|
|
222
|
-
"
|
|
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")
|
|
223
263
|
return 0
|
package/harness/harness/epic.py
CHANGED
|
@@ -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
|
-
|
|
200
|
-
|
|
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
|
)
|
package/harness/harness/gate.py
CHANGED
|
@@ -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 .
|
|
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 "
|
package/harness/harness/git.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
783
|
-
#
|
|
784
|
-
|
|
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
|
-
|
|
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
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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"
|
package/harness/harness/lint.py
CHANGED
|
@@ -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
|
|
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:
|
package/harness/harness/shard.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 /
|
|
96
|
-
|
|
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"
|
|
@@ -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
|
},
|
package/harness/test_work.py
CHANGED
|
@@ -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", "
|
|
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
|
-
{"
|
|
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
|
-
{"
|
|
6779
|
+
{"sources": ["pkg/x.test.ts"], "runner": "vitest",
|
|
6723
6780
|
"covered": {"viewer/AC-01": {"status": "passed"}}}))
|
|
6724
6781
|
|
|
6725
6782
|
config.apply({**config.DEFAULTS,
|
|
@@ -6894,6 +6951,49 @@ def test_four_board_writes_racing_a_rejected_push_lose_nothing():
|
|
|
6894
6951
|
config.apply(config.DEFAULTS)
|
|
6895
6952
|
|
|
6896
6953
|
|
|
6954
|
+
def test_config_unset_with_no_key_says_so_rather_than_throwing():
|
|
6955
|
+
# The repair branch — `unset` may remove a key the schema does not know — took
|
|
6956
|
+
# the empty key for one of those, because `_present` answers True for a path of
|
|
6957
|
+
# no segments. It then indexed the last of zero parts, so the command an
|
|
6958
|
+
# installer shells out to answered with an IndexError traceback.
|
|
6959
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
6960
|
+
repo = _repo_with_config(tmp, {"ids": {"prefix": "G"}})
|
|
6961
|
+
for key in ("", ".", " "):
|
|
6962
|
+
try:
|
|
6963
|
+
_cli(repo, "config", "unset", key)
|
|
6964
|
+
assert False, f"unset {key!r} should refuse"
|
|
6965
|
+
except SystemExit as e:
|
|
6966
|
+
assert e.code, f"unset {key!r} exited 0"
|
|
6967
|
+
assert json.loads((repo / ".claude" / "work.config.json").read_text()) == \
|
|
6968
|
+
{"ids": {"prefix": "G"}}, "a refused unset changed the file"
|
|
6969
|
+
|
|
6970
|
+
|
|
6971
|
+
def test_asking_where_the_gates_stand_never_commits_the_board():
|
|
6972
|
+
# `verify` writes the result of a run it EXECUTES, and two of its four doors
|
|
6973
|
+
# execute nothing: `--async` starts a detached child that commits its own
|
|
6974
|
+
# result, `--status` only reports. Both were driving a commit that swept up
|
|
6975
|
+
# whatever the person had open in work/, as "docs(work): board edits" under no
|
|
6976
|
+
# item — seen twice on this repo's own board in one session, while polling for a
|
|
6977
|
+
# gate to finish, which is exactly the call a session repeats.
|
|
6978
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
6979
|
+
try:
|
|
6980
|
+
repo = _git_repo_cli(tmp)
|
|
6981
|
+
(repo / ".claude" / "work.config.json").write_text(json.dumps(
|
|
6982
|
+
{"git": {"commit": True, "push": False, "remote": "origin",
|
|
6983
|
+
"paths": ["work"]},
|
|
6984
|
+
"verify": {"tests": "true"}}))
|
|
6985
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-qm", "gates configured")
|
|
6986
|
+
(repo / "work" / "product" / "README.md").write_text("a hand edit\n")
|
|
6987
|
+
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
6988
|
+
|
|
6989
|
+
_entry(repo, "verify", "--task", "alpha", "--status")
|
|
6990
|
+
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before, \
|
|
6991
|
+
"asking where the gates stand committed the board"
|
|
6992
|
+
assert "work/product/README.md" in _git(repo, "status", "--porcelain").stdout
|
|
6993
|
+
finally:
|
|
6994
|
+
config.apply(config.DEFAULTS)
|
|
6995
|
+
|
|
6996
|
+
|
|
6897
6997
|
if __name__ == "__main__":
|
|
6898
6998
|
tests = [v for k, v in sorted(globals().items())
|
|
6899
6999
|
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
|
|
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.
|
|
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",
|
|
60
59
|
"@jarvis/anthropic": "1.0.0",
|
|
61
60
|
"@jarvis/board": "0.1.0",
|
|
62
61
|
"@jarvis/data": "0.1.0",
|
|
62
|
+
"@jarvis/logger": "1.0.0",
|
|
63
63
|
"@jarvis/errors": "1.0.0",
|
|
64
64
|
"@jarvis/rpc": "1.0.0",
|
|
65
|
-
"@jarvis/logger": "1.0.0",
|
|
66
65
|
"@jarvis/types": "1.0.0",
|
|
67
66
|
"@jarvis/typescript-config": "1.0.0",
|
|
68
67
|
"@jarvis/ui": "0.1.0",
|
|
69
|
-
"@jarvis/vitest-config": "1.0.0"
|
|
68
|
+
"@jarvis/vitest-config": "1.0.0",
|
|
69
|
+
"@jarvis/agents": "1.0.0"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|
|
72
72
|
"dev": "tsx watch src/bin.ts start --foreground",
|