@appchy/jarvis 0.1.84 → 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 +1 -1
- package/harness/harness/autonomy.py +20 -8
- package/harness/harness/branches.py +20 -7
- package/harness/harness/config.py +31 -3
- package/harness/harness/coverage.py +25 -11
- package/harness/harness/epic.py +22 -6
- package/harness/harness/events.py +18 -4
- package/harness/harness/git.py +68 -26
- package/harness/harness/lint.py +19 -7
- package/harness/harness/report.py +2 -54
- package/harness/harness/safety.py +12 -1
- package/harness/harness/shard.py +7 -1
- package/harness/harness/shift.py +9 -3
- package/harness/harness/task.py +11 -5
- package/harness/schema/work.config.schema.json +1 -1
- package/harness/test_work.py +735 -1
- package/harness/work.py +57 -51
- package/package.json +5 -5
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 = "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" : ""}`;
|
|
@@ -41,18 +41,30 @@ CEILING = DEFAULT_AUTONOMY_CEILING
|
|
|
41
41
|
def derive_tier(owner: str, code: list, given=None) -> tuple:
|
|
42
42
|
"""A task's tier, and the one-line reason for it.
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
Two numbers, and conflating them cost the vocabulary its bottom entry. The
|
|
45
|
+
DEFAULT is what a task gets when nobody says — an ordinary change, tier 1. The
|
|
46
|
+
FLOOR is the lowest anybody may set, and it comes from the tree so it cannot be
|
|
47
|
+
forgotten the way a hand-set field can. They are the same number only for an
|
|
48
|
+
owner that floors a task at 3.
|
|
49
|
+
|
|
50
|
+
Using the default as the floor made `--tier 0` refused every time, on a tier
|
|
51
|
+
that `TIERS` and `TIER_MEANING` both define and the method's own table
|
|
52
|
+
documents: reversible and local, a typo, a comment, a test name. Nothing about
|
|
53
|
+
autonomy changes — the ceiling is 2, so 0 and 1 are alike under it — and the
|
|
54
|
+
rule that matters is untouched: a task whose owner floors it at 3 still cannot
|
|
55
|
+
be talked down.
|
|
56
|
+
|
|
57
|
+
Returns `(tier, why)` — `why` is printed on refusal, because a floor that will
|
|
58
|
+
not say which signal set it is a floor people argue with.
|
|
48
59
|
"""
|
|
49
|
-
floor, why = 1, "default for an ordinary change"
|
|
60
|
+
floor, default, why = 0, 1, "default for an ordinary change"
|
|
50
61
|
owner_root = (owner or "").split("/")[0]
|
|
51
62
|
if owner_root in TIER3_OWNERS:
|
|
52
|
-
floor
|
|
63
|
+
floor = default = 3
|
|
64
|
+
why = f"owner: {owner_root} — this domain is always tier 3"
|
|
53
65
|
|
|
54
66
|
if given is None:
|
|
55
|
-
return
|
|
67
|
+
return default, why
|
|
56
68
|
try:
|
|
57
69
|
want = int(str(given).strip())
|
|
58
70
|
except ValueError:
|
|
@@ -63,7 +75,7 @@ def derive_tier(owner: str, code: list, given=None) -> tuple:
|
|
|
63
75
|
die(f"--tier {want} is below this task's derived floor of {floor} ({why}).\n"
|
|
64
76
|
f" A tier may be raised, never lowered — a run that can talk its "
|
|
65
77
|
f"own blast radius down has no ceiling at all.")
|
|
66
|
-
return want, ("as given" if want ==
|
|
78
|
+
return want, ("as given" if want == default else f"set from {default} ({why})")
|
|
67
79
|
|
|
68
80
|
|
|
69
81
|
def tier_of(task) -> int:
|
|
@@ -82,8 +82,13 @@ def branches_of(repo, name: str) -> list:
|
|
|
82
82
|
file and has never touched it is not where the work is happening. The trailers
|
|
83
83
|
slice 1 started writing are what make this answerable at all.
|
|
84
84
|
"""
|
|
85
|
+
# `%at` rides along with `%aI` because the two answer different questions. The
|
|
86
|
+
# ISO stamp carries its author's own offset and is what a reader wants to see;
|
|
87
|
+
# comparing two of those AS TEXT compares wall clocks in different timezones, so
|
|
88
|
+
# a commit made earlier somewhere east presents as the newest. The epoch is the
|
|
89
|
+
# instant, and ordering is a question about instants.
|
|
85
90
|
code, out, _ = git._git(
|
|
86
|
-
repo, "log", "--all", "--format=%H%x1f%aI%x1f%an", "-E",
|
|
91
|
+
repo, "log", "--all", "--format=%H%x1f%aI%x1f%an%x1f%at", "-E",
|
|
87
92
|
f"--grep=^{git.ITEM}: {re.escape(name)}$", timeout=60)
|
|
88
93
|
if code != 0 or not out.strip():
|
|
89
94
|
return []
|
|
@@ -91,14 +96,19 @@ def branches_of(repo, name: str) -> list:
|
|
|
91
96
|
seen = {}
|
|
92
97
|
for line in out.splitlines():
|
|
93
98
|
parts = line.split("\x1f")
|
|
94
|
-
if len(parts) <
|
|
99
|
+
if len(parts) < 4:
|
|
95
100
|
continue
|
|
96
101
|
sha, when, who = parts[0], parts[1], parts[2]
|
|
102
|
+
try:
|
|
103
|
+
at = int(parts[3])
|
|
104
|
+
except ValueError:
|
|
105
|
+
continue
|
|
97
106
|
for branch in _containing(repo, sha):
|
|
98
107
|
prior = seen.get(branch)
|
|
99
|
-
if not prior or
|
|
100
|
-
seen[branch] = {"branch": branch, "when": when, "by": who,
|
|
101
|
-
|
|
108
|
+
if not prior or at > prior["at"]:
|
|
109
|
+
seen[branch] = {"branch": branch, "when": when, "by": who,
|
|
110
|
+
"sha": sha[:12], "at": at}
|
|
111
|
+
return sorted(seen.values(), key=lambda b: b["at"], reverse=True)
|
|
102
112
|
|
|
103
113
|
|
|
104
114
|
def _containing(repo, sha: str) -> list:
|
|
@@ -151,7 +161,11 @@ def refs_state(repo) -> dict:
|
|
|
151
161
|
# back with the separator glued into its name and matched nothing. A space is
|
|
152
162
|
# safe here because git forbids one in a ref name, and the strict date carries
|
|
153
163
|
# none either.
|
|
154
|
-
|
|
164
|
+
# GIT orders them, newest first. Sorting the strict ISO stamps here as text
|
|
165
|
+
# compared wall clocks rather than instants: each carries its author's own
|
|
166
|
+
# offset, so a ref committed at 10:00+03:00 sorted above one committed at
|
|
167
|
+
# 09:00+00:00 — two hours EARLIER — and `at` then read the wrong ref as current.
|
|
168
|
+
code, out, _ = git._git(repo, "for-each-ref", "--sort=-committerdate",
|
|
155
169
|
"--format=%(refname:short) %(committerdate:iso8601-strict)",
|
|
156
170
|
"refs/heads", "refs/remotes")
|
|
157
171
|
refs = []
|
|
@@ -160,7 +174,6 @@ def refs_state(repo) -> dict:
|
|
|
160
174
|
name, _, when = line.strip().partition(" ")
|
|
161
175
|
if name and "HEAD" not in name:
|
|
162
176
|
refs.append({"ref": name, "when": when.strip()})
|
|
163
|
-
refs.sort(key=lambda r: r["when"], reverse=True)
|
|
164
177
|
return {"refs": refs, "synced": synced, "current": current(repo)}
|
|
165
178
|
|
|
166
179
|
|
|
@@ -548,9 +548,13 @@ 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
|
+
shard = cfg["coverage"]["shard"]
|
|
555
|
+
if not isinstance(shard, str) or not shard.strip() or _escapes(shard):
|
|
556
|
+
raise ConfigError("coverage.shard must be a repo-relative directory where "
|
|
557
|
+
"test runners drop their evidence, e.g. \".work/coverage\"")
|
|
554
558
|
at = cfg["wrap"]["at_percent"]
|
|
555
559
|
# 100 is refused along with 0: a reminder that arrives once the window is
|
|
556
560
|
# already full has nowhere to write the handoff it is asking for.
|
|
@@ -582,6 +586,19 @@ def _validate(cfg: dict) -> None:
|
|
|
582
586
|
"session up, or null to remind without naming one")
|
|
583
587
|
|
|
584
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
|
+
|
|
585
602
|
def load(repo: Path) -> dict:
|
|
586
603
|
"""The merged config for a repo. Missing file → the defaults, which is a
|
|
587
604
|
working configuration and not an error: a fresh install must run."""
|
|
@@ -608,7 +625,7 @@ def apply(cfg: dict) -> None:
|
|
|
608
625
|
through `registry` → `ids`.
|
|
609
626
|
"""
|
|
610
627
|
from . import (align, autonomy, coverage, gate, git, ids, kickoff, lint, registry,
|
|
611
|
-
shift, task, tree)
|
|
628
|
+
shard, shift, task, tree)
|
|
612
629
|
ids.configure(cfg["ids"]["prefix"], tuple(cfg["ids"]["recognised"]),
|
|
613
630
|
bool(cfg["ids"]["undashed"]))
|
|
614
631
|
git.GIT = dict(cfg["git"])
|
|
@@ -632,6 +649,11 @@ def apply(cfg: dict) -> None:
|
|
|
632
649
|
tree.SKIP_DIRS = tree.SHIPPED_SKIP_DIRS | set(cfg["skip_dirs"])
|
|
633
650
|
tree.TASK_TAGS_OK = tuple(cfg["tags"]["allowed"])
|
|
634
651
|
coverage.VERIFY = dict(cfg["verify"])
|
|
652
|
+
# A DEFAULTED, DOCUMENTED key that nothing read: the reader hardcoded
|
|
653
|
+
# `.work/coverage`, so a repo that pointed its runners somewhere else got no
|
|
654
|
+
# error and no effect — and then `coverage` reported "no evidence" for every
|
|
655
|
+
# criterion a run had actually proved.
|
|
656
|
+
shard.DIR = cfg["coverage"]["shard"]
|
|
635
657
|
gate.VERIFY = dict(cfg["verify"])
|
|
636
658
|
task.PLANS_DIR = cfg["plans"]["dir"]
|
|
637
659
|
autonomy.CEILING = cfg["autonomy"]["ceiling"]
|
|
@@ -1415,7 +1437,13 @@ def _cmd_config_write(args) -> int:
|
|
|
1415
1437
|
# and refusing to remove it would leave the only tool that can fix it
|
|
1416
1438
|
# refusing on the grounds that the thing being fixed is broken.
|
|
1417
1439
|
parts = [seg for seg in dotted.split(".") if seg]
|
|
1418
|
-
|
|
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):
|
|
1419
1447
|
raise
|
|
1420
1448
|
unknown = True
|
|
1421
1449
|
|
|
@@ -31,6 +31,7 @@ from pathlib import Path
|
|
|
31
31
|
|
|
32
32
|
from .lint import feature_ac_levels
|
|
33
33
|
from .model import scan_features
|
|
34
|
+
from . import shard
|
|
34
35
|
from .shard import _load_run
|
|
35
36
|
from .tree import find_work_root
|
|
36
37
|
|
|
@@ -81,7 +82,7 @@ def cmd_coverage(args) -> int:
|
|
|
81
82
|
# that is why the scan in the suite checks command names too.
|
|
82
83
|
lines = [f" {cmd}" + (f" ({name})" if name else "")
|
|
83
84
|
for name, cmd in sorted(VERIFY.items())]
|
|
84
|
-
print("\n no run found —
|
|
85
|
+
print(f"\n no run found — `{shard.DIR}/` is empty.\n"
|
|
85
86
|
" Evidence is a fresh run, so produce one first:\n"
|
|
86
87
|
+ ("\n".join(lines) if lines
|
|
87
88
|
else " (no `verify` commands configured — add them to "
|
|
@@ -89,8 +90,16 @@ def cmd_coverage(args) -> int:
|
|
|
89
90
|
return 0
|
|
90
91
|
|
|
91
92
|
only = args.get("feature")
|
|
92
|
-
|
|
93
|
-
|
|
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}
|
|
94
103
|
mismatched = []
|
|
95
104
|
|
|
96
105
|
for md in sorted(scan_features(root)):
|
|
@@ -123,7 +132,6 @@ def cmd_coverage(args) -> int:
|
|
|
123
132
|
ahead = passed - built
|
|
124
133
|
provable = built - eyes
|
|
125
134
|
proven = (built & passed) - eyes
|
|
126
|
-
|
|
127
135
|
for ac in sorted(declared):
|
|
128
136
|
level = levels[ac][0]
|
|
129
137
|
complaint = _wrong_level(level, runners.get(f"{name}/{ac}", set())) if level else None
|
|
@@ -131,15 +139,17 @@ def cmd_coverage(args) -> int:
|
|
|
131
139
|
mismatched.append(f"{name}/{ac}: {complaint}")
|
|
132
140
|
|
|
133
141
|
totals["declared"] += len(declared)
|
|
134
|
-
totals["built"] += len(
|
|
142
|
+
totals["built"] += len(built)
|
|
143
|
+
totals["provable"] += len(provable)
|
|
135
144
|
totals["proven"] += len(proven)
|
|
136
145
|
totals["failed"] += len(failed)
|
|
137
146
|
totals["todo"] += len(built & todo)
|
|
138
147
|
totals["gap"] += len(gap)
|
|
139
148
|
totals["ahead"] += len(ahead)
|
|
140
|
-
totals["eyes"] += len(eyes)
|
|
141
149
|
totals["unlevelled"] += sum(1 for ac in declared if levels[ac][0] is None)
|
|
142
|
-
|
|
150
|
+
# The column is the ratio's own denominator, so it counts what the ratio
|
|
151
|
+
# divides by and not everything ticked.
|
|
152
|
+
rows.append((name, len(provable), len(proven), len(eyes),
|
|
143
153
|
len(built & todo), len(declared - built),
|
|
144
154
|
sorted(failed), sorted(gap), sorted(ahead)))
|
|
145
155
|
|
|
@@ -180,16 +190,20 @@ def cmd_coverage(args) -> int:
|
|
|
180
190
|
f"{unbuilt:>8} {note}")
|
|
181
191
|
|
|
182
192
|
t = totals
|
|
183
|
-
|
|
184
|
-
|
|
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} "
|
|
185
199
|
f"— of the RUN-PROVABLE promises this app keeps, how many a run proves\n"
|
|
186
200
|
f" {t['gap']} claimed with nothing to show · {t['todo']} declared-but-unwritten · "
|
|
187
201
|
f"{t['failed']} failing\n")
|
|
188
|
-
print(f" Settled by eyes, not by a run: {
|
|
202
|
+
print(f" Settled by eyes, not by a run: {eyes}. A look is a level, not an\n"
|
|
189
203
|
f" excuse — no assertion is evidence about weight, colour or rhythm — so\n"
|
|
190
204
|
f" these sit beside the ratio with the dated ✔ in the feature file as their\n"
|
|
191
205
|
f" evidence, never inside it.\n")
|
|
192
|
-
print(f" Not built yet: {
|
|
206
|
+
print(f" Not built yet: {unbuilt} of {t['declared']} promises. "
|
|
193
207
|
f"That is a roadmap, NOT a coverage hole —\n"
|
|
194
208
|
f" an unticked criterion is behaviour nobody has written, so counting it\n"
|
|
195
209
|
f" against coverage measures ambition rather than honesty.\n")
|
package/harness/harness/epic.py
CHANGED
|
@@ -192,12 +192,22 @@ def cmd_epic_move(args) -> int:
|
|
|
192
192
|
for t in moved:
|
|
193
193
|
shutil.move(str(dest / t.name), str(queue / t.name))
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
# Only a PLANNED epic has a plan doc to stamp. A released cut has had every
|
|
196
|
+
# `epic.md` removed, so an epic promoted out of one arrives with nothing to
|
|
197
|
+
# write — and reading it anyway raised after the folders had already moved,
|
|
198
|
+
# leaving a half-moved tree no CLI command could put back.
|
|
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:
|
|
206
|
+
rewrite_file(
|
|
207
|
+
dest / "epic.md",
|
|
208
|
+
lambda d: d.update({"updated": date.today().isoformat()}),
|
|
209
|
+
EPIC_FM_ORDER,
|
|
210
|
+
)
|
|
201
211
|
print(f"pulled epic '{name}' ({was}) -> {rel(dest, root)} "
|
|
202
212
|
f"with {len(moved)} task(s)")
|
|
203
213
|
_sync(root)
|
|
@@ -211,6 +221,12 @@ def cmd_epic_release(root, version) -> int:
|
|
|
211
221
|
holds the plan. Called from `cmd_release`, never on its own."""
|
|
212
222
|
removed = []
|
|
213
223
|
for e in version.epics:
|
|
224
|
+
# An epic with no plan doc is already in the shape this produces — a folder
|
|
225
|
+
# grouping tasks. Unlinking regardless raised AFTER `released:` had been
|
|
226
|
+
# stamped, so the cut read as released while every other epic kept the file
|
|
227
|
+
# this exists to remove.
|
|
228
|
+
if not e.planned:
|
|
229
|
+
continue
|
|
214
230
|
e.md.unlink()
|
|
215
231
|
removed.append(e.name)
|
|
216
232
|
if removed:
|
|
@@ -49,6 +49,14 @@ LOG = ".events.jsonl"
|
|
|
49
49
|
#: replicated one is a claim two endpoints both believe they have.
|
|
50
50
|
HOLDS = ("claimed", "released-claim")
|
|
51
51
|
|
|
52
|
+
#: Events no commit ever carries. The holds above, and a REFUSAL — which changed
|
|
53
|
+
#: nothing, so there is no commit for it to ride on. Buffering a refusal made the
|
|
54
|
+
#: git seam see work waiting for a commit while the tree was untouched, and report
|
|
55
|
+
#: the change as "carried by a board write that committed a moment earlier": a
|
|
56
|
+
#: refused completion, told its work was safely in git under somebody else's commit,
|
|
57
|
+
#: when nothing had been written and nothing committed.
|
|
58
|
+
UNCOMMITTED = HOLDS + ("gate-refused",)
|
|
59
|
+
|
|
52
60
|
#: What this command has done so far, waiting for the commit that carries it. Only
|
|
53
61
|
#: used under git; a process runs one command, so one buffer is one commit.
|
|
54
62
|
_PENDING: list = []
|
|
@@ -90,9 +98,15 @@ def instance_id() -> str:
|
|
|
90
98
|
def append(root: Path, kind: str, name: str, **fields) -> None:
|
|
91
99
|
"""Append one line. Never raises: a failure to record must not fail the work
|
|
92
100
|
that was recorded — the mutation already happened, and dying here would leave
|
|
93
|
-
the tree changed and the caller told it failed.
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
the tree changed and the caller told it failed.
|
|
102
|
+
|
|
103
|
+
A kind outside `KINDS` is recorded anyway rather than refused. The closed list
|
|
104
|
+
still exists, and is still the point — a typo'd kind is a line that never shows
|
|
105
|
+
up in a digest — but the place to catch it is the SUITE, which reads every call
|
|
106
|
+
site out of this package's own source. Raising here caught it in front of a user
|
|
107
|
+
instead, after the tree had already changed, on a promise this docstring makes
|
|
108
|
+
in its first sentence.
|
|
109
|
+
"""
|
|
96
110
|
entry = {"ts": _now(), "event": kind, "name": name}
|
|
97
111
|
who = instance_id()
|
|
98
112
|
if who:
|
|
@@ -102,7 +116,7 @@ def append(root: Path, kind: str, name: str, **fields) -> None:
|
|
|
102
116
|
# the bucket already states.
|
|
103
117
|
entry.update({k: v for k, v in fields.items() if v not in (None, "", [], {})})
|
|
104
118
|
if git.enabled():
|
|
105
|
-
if kind not in
|
|
119
|
+
if kind not in UNCOMMITTED:
|
|
106
120
|
_PENDING.append(entry)
|
|
107
121
|
return
|
|
108
122
|
try:
|
package/harness/harness/git.py
CHANGED
|
@@ -78,15 +78,22 @@ def _flat(value) -> str:
|
|
|
78
78
|
return str(value).replace("\n", " ").replace("\r", " ").replace(SEP, " - ")
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
#:
|
|
82
|
-
#:
|
|
83
|
-
#:
|
|
84
|
-
#:
|
|
81
|
+
#: EVERY COMMAND THAT CHANGES THE BOARD, and the whole of what the git seam acts on:
|
|
82
|
+
#: these pull before they act and commit what they wrote, and no other command does
|
|
83
|
+
#: either. A name missing from here is a command that mutates the tree and never
|
|
84
|
+
#: lands it — the exact hole the commit-on-every-write guarantee exists to close — so
|
|
85
|
+
#: adding a mutating command means adding it here, and the suite checks that every
|
|
86
|
+
#: command outside this set leaves the tree untouched.
|
|
87
|
+
#:
|
|
88
|
+
#: `new` and `task-new` are one verb and both spellings are listed, because the
|
|
89
|
+
#: dispatcher matches on what was typed. `sync` is absent although it reaches the
|
|
90
|
+
#: origin: it refreshes and sends on its own, and being in both places is two fetches
|
|
91
|
+
#: for one command.
|
|
85
92
|
WRITES = frozenset({
|
|
86
|
-
"task-new", "
|
|
87
|
-
"place", "move", "handoff", "plan", "session", "release",
|
|
88
|
-
"next", "drop", "ask", "answer", "verify", "observed", "id-new",
|
|
89
|
-
"migrate
|
|
93
|
+
"task-new", "new", "epic-new", "feature-new", "version-new", "domain-new",
|
|
94
|
+
"system-new", "place", "move", "handoff", "plan", "session", "release",
|
|
95
|
+
"archive", "next", "drop", "ask", "answer", "verify", "observed", "id-new",
|
|
96
|
+
"readme", "init", "migrate",
|
|
90
97
|
})
|
|
91
98
|
|
|
92
99
|
|
|
@@ -661,16 +668,21 @@ def _push(repo) -> tuple:
|
|
|
661
668
|
def _why_no_rebase(err: str) -> str:
|
|
662
669
|
"""Why the rebase onto the moved branch did not run, in the caller's terms.
|
|
663
670
|
|
|
664
|
-
The common case is not a conflict at all: the branch moved while
|
|
665
|
-
|
|
671
|
+
The common case is not a conflict at all: the branch moved while something in the
|
|
672
|
+
tree was uncommitted, and git declines to rebase over it. That reads as a scary
|
|
666
673
|
failure and is an ordinary one, so it is named separately and says what to do —
|
|
667
674
|
the board commit is already in git, and only the push is waiting.
|
|
668
675
|
"""
|
|
669
676
|
if re.search(r"unstaged changes|uncommitted changes|cannot pull with rebase|"
|
|
670
677
|
r"cannot rebase.*(dirty|unstaged)", err, re.I):
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
678
|
+
# NOT "your own edits". Measured with four writers racing: what was in the
|
|
679
|
+
# way was another board write's task.md, created and not yet committed. A
|
|
680
|
+
# message that names the reader as the owner of somebody else's file sends
|
|
681
|
+
# them looking for work they do not have.
|
|
682
|
+
return ("the branch moved, and uncommitted changes in this checkout are in "
|
|
683
|
+
"the way of rebasing onto it — nothing was moved or stashed. They "
|
|
684
|
+
"may be yours or another board write that has not committed yet; "
|
|
685
|
+
"commit what is yours and the next board write pushes both")
|
|
674
686
|
return f"the branch moved and the rebase onto it did not apply: {_tail(err)}"
|
|
675
687
|
|
|
676
688
|
|
|
@@ -755,6 +767,9 @@ def _events(record: str) -> list:
|
|
|
755
767
|
who = (trailers.get(SESSION) or [""])[0]
|
|
756
768
|
host = (trailers.get(MACHINE) or [""])[0]
|
|
757
769
|
out = []
|
|
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()}
|
|
758
773
|
for kind in kinds:
|
|
759
774
|
e = {"ts": _utc(when), "event": kind, "name": item, "sha": sha[:12],
|
|
760
775
|
"author": author}
|
|
@@ -762,7 +777,7 @@ def _events(record: str) -> list:
|
|
|
762
777
|
e["by"] = who
|
|
763
778
|
if host:
|
|
764
779
|
e["machine"] = host
|
|
765
|
-
e.update(
|
|
780
|
+
e.update(next(pools.get(kind, iter(())), {}))
|
|
766
781
|
out.append(e)
|
|
767
782
|
return out
|
|
768
783
|
|
|
@@ -822,27 +837,35 @@ def _trailers(message: str) -> dict:
|
|
|
822
837
|
|
|
823
838
|
|
|
824
839
|
def _details(message: str, kinds) -> dict:
|
|
825
|
-
"""The body's per-event
|
|
840
|
+
"""The body's per-event payloads, matched back to their events by name — as a
|
|
841
|
+
LIST per kind, in the order the body carries them.
|
|
826
842
|
|
|
827
843
|
A round trip rather than prose, so `log`, `digest` and `status` print the same
|
|
828
844
|
thing whichever backend the repo runs. `_flat` is what makes it safe: the
|
|
829
845
|
separator can never appear inside a value, so a question with an odd character
|
|
830
846
|
in it comes back whole instead of splitting into a field nobody wrote.
|
|
847
|
+
|
|
848
|
+
One payload per KIND was the earlier shape, and a command can record two events
|
|
849
|
+
of one kind in a single commit — `verify` files one `verified` per gate. The
|
|
850
|
+
second line was dropped and both events came back wearing the first's fields, so
|
|
851
|
+
a digest read one gate's result twice and never reported the other at all. The
|
|
852
|
+
body is written one line per event in order, so position within a kind is exactly
|
|
853
|
+
the pairing.
|
|
831
854
|
"""
|
|
832
|
-
out = {}
|
|
855
|
+
out: dict = {}
|
|
833
856
|
for line in message.splitlines():
|
|
834
857
|
line = line.strip()
|
|
835
858
|
if _TRAILER.match(line):
|
|
836
859
|
continue
|
|
837
860
|
head, _, rest = line.partition(" ")
|
|
838
|
-
if head not in kinds
|
|
861
|
+
if head not in kinds:
|
|
839
862
|
continue
|
|
840
863
|
fields = {}
|
|
841
864
|
for chunk in rest.split(SEP):
|
|
842
865
|
key, sign, value = chunk.partition("=")
|
|
843
866
|
if sign and key.strip():
|
|
844
867
|
fields[key.strip()] = value.strip()
|
|
845
|
-
out[
|
|
868
|
+
out.setdefault(head, []).append(fields)
|
|
846
869
|
return out
|
|
847
870
|
|
|
848
871
|
|
|
@@ -885,12 +908,31 @@ def cmd_sync(args=None) -> int:
|
|
|
885
908
|
return 0 if pushed else 1
|
|
886
909
|
|
|
887
910
|
|
|
911
|
+
#: Git saying what went wrong, wherever in its output that sits.
|
|
912
|
+
_COMPLAINT = re.compile(r"(?:error|fatal): (.+)")
|
|
913
|
+
|
|
914
|
+
|
|
888
915
|
def _tail(text: str) -> str:
|
|
889
|
-
"""Git's
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
@@ -12,7 +12,7 @@ from .tree import (
|
|
|
12
12
|
TASK_REGION_CAP,
|
|
13
13
|
)
|
|
14
14
|
from .frontmatter import as_list, parse_frontmatter, split_frontmatter
|
|
15
|
-
from .model import locate_feature, scan, scan_features
|
|
15
|
+
from .model import locate_feature, scan, scan_features, scan_filed
|
|
16
16
|
from .registry import code_vocabulary, locate_domain
|
|
17
17
|
|
|
18
18
|
#: The MCP server this repo names as its graph engine, bound by `config.apply`.
|
|
@@ -318,10 +318,9 @@ def _owner_ref_lint(root: Path, s: dict) -> list:
|
|
|
318
318
|
for t in [task for v in s["versions"] for task in v.all_tasks()] + s["backlog"]:
|
|
319
319
|
# The rename warning is ACTIVE work only — a completed task's frontmatter
|
|
320
320
|
# is frozen history, and 90 lines of migration noise makes `list` unreadable.
|
|
321
|
-
# `migrate-owner` rewrites every task regardless of bucket.
|
|
322
321
|
if t.legacy_product and t.status in (None,) + ACTIVE:
|
|
323
322
|
warns.append(f"{t.name}: `product:` is the old spelling — rename the "
|
|
324
|
-
f"key to `owner:`
|
|
323
|
+
f"key to `owner:` in its task.md")
|
|
325
324
|
value = t.owner
|
|
326
325
|
if not value:
|
|
327
326
|
continue # empty is _graph_lint's warning, and only for active work
|
|
@@ -482,8 +481,23 @@ def _coverage_lint(root: Path, s: dict) -> list:
|
|
|
482
481
|
if not product_dir.is_dir():
|
|
483
482
|
return warns
|
|
484
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
|
+
|
|
485
493
|
covered: dict = {}
|
|
486
|
-
|
|
494
|
+
# The cuts that have LEFT the board carry evidence too — a criterion delivered
|
|
495
|
+
# by a task in a shipped cut is delivered. Reading the live board alone turned
|
|
496
|
+
# filing a cut into a permanent warning about every AC it delivered, with no
|
|
497
|
+
# way left to satisfy it.
|
|
498
|
+
filed = [task for v in scan_filed(root) for task in v.all_tasks()]
|
|
499
|
+
for t in ([task for v in s["versions"] for task in v.all_tasks()]
|
|
500
|
+
+ s["backlog"] + filed):
|
|
487
501
|
if t.status != "complete" or not t.covers:
|
|
488
502
|
continue
|
|
489
503
|
if t.owner:
|
|
@@ -495,10 +509,8 @@ def _coverage_lint(root: Path, s: dict) -> list:
|
|
|
495
509
|
# matched nothing and this whole rollup was dead code wearing a passing
|
|
496
510
|
# test. `scan_features` is now the single owner of that glob, so the next
|
|
497
511
|
# layout change cannot leave one caller behind.)
|
|
498
|
-
for feature_md in
|
|
512
|
+
for feature_md in shipped:
|
|
499
513
|
text = feature_md.read_text()
|
|
500
|
-
if parse_frontmatter(text).get("state") != "shipped":
|
|
501
|
-
continue
|
|
502
514
|
have = covered.get(feature_md.stem, set())
|
|
503
515
|
for ac in sorted(_feature_ac_ids(text)):
|
|
504
516
|
if ac not in have:
|