@appchy/jarvis 0.1.84 → 0.1.85
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 +11 -1
- package/harness/harness/coverage.py +13 -4
- package/harness/harness/epic.py +16 -5
- package/harness/harness/events.py +18 -4
- package/harness/harness/git.py +58 -19
- package/harness/harness/lint.py +9 -4
- 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/test_work.py +692 -1
- package/harness/work.py +38 -51
- package/package.json +7 -7
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 = "f9f9db3";
|
|
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
|
|
|
@@ -551,6 +551,11 @@ def _validate(cfg: dict) -> None:
|
|
|
551
551
|
if Path(p).is_absolute() or ".." in Path(p).parts:
|
|
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()
|
|
556
|
+
or Path(shard).is_absolute() or ".." in Path(shard).parts):
|
|
557
|
+
raise ConfigError("coverage.shard must be a repo-relative directory where "
|
|
558
|
+
"test runners drop their evidence, e.g. \".work/coverage\"")
|
|
554
559
|
at = cfg["wrap"]["at_percent"]
|
|
555
560
|
# 100 is refused along with 0: a reminder that arrives once the window is
|
|
556
561
|
# already full has nowhere to write the handoff it is asking for.
|
|
@@ -608,7 +613,7 @@ def apply(cfg: dict) -> None:
|
|
|
608
613
|
through `registry` → `ids`.
|
|
609
614
|
"""
|
|
610
615
|
from . import (align, autonomy, coverage, gate, git, ids, kickoff, lint, registry,
|
|
611
|
-
shift, task, tree)
|
|
616
|
+
shard, shift, task, tree)
|
|
612
617
|
ids.configure(cfg["ids"]["prefix"], tuple(cfg["ids"]["recognised"]),
|
|
613
618
|
bool(cfg["ids"]["undashed"]))
|
|
614
619
|
git.GIT = dict(cfg["git"])
|
|
@@ -632,6 +637,11 @@ def apply(cfg: dict) -> None:
|
|
|
632
637
|
tree.SKIP_DIRS = tree.SHIPPED_SKIP_DIRS | set(cfg["skip_dirs"])
|
|
633
638
|
tree.TASK_TAGS_OK = tuple(cfg["tags"]["allowed"])
|
|
634
639
|
coverage.VERIFY = dict(cfg["verify"])
|
|
640
|
+
# A DEFAULTED, DOCUMENTED key that nothing read: the reader hardcoded
|
|
641
|
+
# `.work/coverage`, so a repo that pointed its runners somewhere else got no
|
|
642
|
+
# error and no effect — and then `coverage` reported "no evidence" for every
|
|
643
|
+
# criterion a run had actually proved.
|
|
644
|
+
shard.DIR = cfg["coverage"]["shard"]
|
|
635
645
|
gate.VERIFY = dict(cfg["verify"])
|
|
636
646
|
task.PLANS_DIR = cfg["plans"]["dir"]
|
|
637
647
|
autonomy.CEILING = cfg["autonomy"]["ceiling"]
|
|
@@ -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 "
|
|
@@ -90,7 +91,8 @@ def cmd_coverage(args) -> int:
|
|
|
90
91
|
|
|
91
92
|
only = args.get("feature")
|
|
92
93
|
rows, totals = [], {"declared": 0, "built": 0, "proven": 0, "failed": 0,
|
|
93
|
-
"todo": 0, "gap": 0, "ahead": 0, "eyes": 0, "
|
|
94
|
+
"todo": 0, "gap": 0, "ahead": 0, "eyes": 0, "unbuilt": 0,
|
|
95
|
+
"unlevelled": 0}
|
|
94
96
|
mismatched = []
|
|
95
97
|
|
|
96
98
|
for md in sorted(scan_features(root)):
|
|
@@ -138,8 +140,15 @@ def cmd_coverage(args) -> int:
|
|
|
138
140
|
totals["gap"] += len(gap)
|
|
139
141
|
totals["ahead"] += len(ahead)
|
|
140
142
|
totals["eyes"] += len(eyes)
|
|
143
|
+
# COUNTED, not derived from `declared - built`. An eyes-on criterion is
|
|
144
|
+
# built and comes out of the ratio, so subtracting the ratio's denominator
|
|
145
|
+
# reported every one of them as behaviour nobody had written yet.
|
|
146
|
+
totals["unbuilt"] += len(declared - built)
|
|
141
147
|
totals["unlevelled"] += sum(1 for ac in declared if levels[ac][0] is None)
|
|
142
|
-
|
|
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.
|
|
151
|
+
rows.append((name, len(provable), len(proven), len(eyes),
|
|
143
152
|
len(built & todo), len(declared - built),
|
|
144
153
|
sorted(failed), sorted(gap), sorted(ahead)))
|
|
145
154
|
|
|
@@ -189,7 +198,7 @@ def cmd_coverage(args) -> int:
|
|
|
189
198
|
f" excuse — no assertion is evidence about weight, colour or rhythm — so\n"
|
|
190
199
|
f" these sit beside the ratio with the dated ✔ in the feature file as their\n"
|
|
191
200
|
f" evidence, never inside it.\n")
|
|
192
|
-
print(f" Not built yet: {t['
|
|
201
|
+
print(f" Not built yet: {t['unbuilt']} of {t['declared']} promises. "
|
|
193
202
|
f"That is a roadmap, NOT a coverage hole —\n"
|
|
194
203
|
f" an unticked criterion is behaviour nobody has written, so counting it\n"
|
|
195
204
|
f" against coverage measures ambition rather than honesty.\n")
|
package/harness/harness/epic.py
CHANGED
|
@@ -192,12 +192,17 @@ 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
|
+
# 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.
|
|
195
199
|
md = dest / "epic.md"
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
if md.is_file():
|
|
201
|
+
rewrite_file(
|
|
202
|
+
md,
|
|
203
|
+
lambda d: d.update({"updated": date.today().isoformat()}),
|
|
204
|
+
EPIC_FM_ORDER,
|
|
205
|
+
)
|
|
201
206
|
print(f"pulled epic '{name}' ({was}) -> {rel(dest, root)} "
|
|
202
207
|
f"with {len(moved)} task(s)")
|
|
203
208
|
_sync(root)
|
|
@@ -211,6 +216,12 @@ def cmd_epic_release(root, version) -> int:
|
|
|
211
216
|
holds the plan. Called from `cmd_release`, never on its own."""
|
|
212
217
|
removed = []
|
|
213
218
|
for e in version.epics:
|
|
219
|
+
# An epic with no plan doc is already in the shape this produces — a folder
|
|
220
|
+
# grouping tasks. Unlinking regardless raised AFTER `released:` had been
|
|
221
|
+
# stamped, so the cut read as released while every other epic kept the file
|
|
222
|
+
# this exists to remove.
|
|
223
|
+
if not e.planned:
|
|
224
|
+
continue
|
|
214
225
|
e.md.unlink()
|
|
215
226
|
removed.append(e.name)
|
|
216
227
|
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
|
|
|
@@ -658,20 +665,37 @@ def _push(repo) -> tuple:
|
|
|
658
665
|
f"`{cli()} sync` sends it when you can reach {remote}.")
|
|
659
666
|
|
|
660
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
|
+
|
|
661
678
|
def _why_no_rebase(err: str) -> str:
|
|
662
679
|
"""Why the rebase onto the moved branch did not run, in the caller's terms.
|
|
663
680
|
|
|
664
|
-
The common case is not a conflict at all: the branch moved while
|
|
665
|
-
|
|
681
|
+
The common case is not a conflict at all: the branch moved while something in the
|
|
682
|
+
tree was uncommitted, and git declines to rebase over it. That reads as a scary
|
|
666
683
|
failure and is an ordinary one, so it is named separately and says what to do —
|
|
667
684
|
the board commit is already in git, and only the push is waiting.
|
|
668
685
|
"""
|
|
669
686
|
if re.search(r"unstaged changes|uncommitted changes|cannot pull with rebase|"
|
|
670
687
|
r"cannot rebase.*(dirty|unstaged)", err, re.I):
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
688
|
+
# NOT "your own edits". Measured with four writers racing: what was in the
|
|
689
|
+
# way was another board write's task.md, created and not yet committed. A
|
|
690
|
+
# message that names the reader as the owner of somebody else's file sends
|
|
691
|
+
# them looking for work they do not have.
|
|
692
|
+
return ("the branch moved, and uncommitted changes in this checkout are in "
|
|
693
|
+
"the way of rebasing onto it — nothing was moved or stashed. They "
|
|
694
|
+
"may be yours or another board write that has not committed yet; "
|
|
695
|
+
"commit what is yours and the next board write pushes both")
|
|
696
|
+
m = _COMPLAINT.search(err or "")
|
|
697
|
+
return ("the branch moved and the rebase onto it did not apply: "
|
|
698
|
+
f"{m.group(1).strip()[:200] if m else _tail(err)}")
|
|
675
699
|
|
|
676
700
|
|
|
677
701
|
def _message(item: str, rows: list) -> tuple:
|
|
@@ -755,6 +779,9 @@ def _events(record: str) -> list:
|
|
|
755
779
|
who = (trailers.get(SESSION) or [""])[0]
|
|
756
780
|
host = (trailers.get(MACHINE) or [""])[0]
|
|
757
781
|
out = []
|
|
782
|
+
# How many of this kind have been handed out already, so the second `verified`
|
|
783
|
+
# in a commit gets the second body line rather than the first's.
|
|
784
|
+
taken: dict = {}
|
|
758
785
|
for kind in kinds:
|
|
759
786
|
e = {"ts": _utc(when), "event": kind, "name": item, "sha": sha[:12],
|
|
760
787
|
"author": author}
|
|
@@ -762,7 +789,11 @@ def _events(record: str) -> list:
|
|
|
762
789
|
e["by"] = who
|
|
763
790
|
if host:
|
|
764
791
|
e["machine"] = host
|
|
765
|
-
|
|
792
|
+
payloads = details.get(kind) or []
|
|
793
|
+
i = taken.get(kind, 0)
|
|
794
|
+
if i < len(payloads):
|
|
795
|
+
e.update(payloads[i])
|
|
796
|
+
taken[kind] = i + 1
|
|
766
797
|
out.append(e)
|
|
767
798
|
return out
|
|
768
799
|
|
|
@@ -822,27 +853,35 @@ def _trailers(message: str) -> dict:
|
|
|
822
853
|
|
|
823
854
|
|
|
824
855
|
def _details(message: str, kinds) -> dict:
|
|
825
|
-
"""The body's per-event
|
|
856
|
+
"""The body's per-event payloads, matched back to their events by name — as a
|
|
857
|
+
LIST per kind, in the order the body carries them.
|
|
826
858
|
|
|
827
859
|
A round trip rather than prose, so `log`, `digest` and `status` print the same
|
|
828
860
|
thing whichever backend the repo runs. `_flat` is what makes it safe: the
|
|
829
861
|
separator can never appear inside a value, so a question with an odd character
|
|
830
862
|
in it comes back whole instead of splitting into a field nobody wrote.
|
|
863
|
+
|
|
864
|
+
One payload per KIND was the earlier shape, and a command can record two events
|
|
865
|
+
of one kind in a single commit — `verify` files one `verified` per gate. The
|
|
866
|
+
second line was dropped and both events came back wearing the first's fields, so
|
|
867
|
+
a digest read one gate's result twice and never reported the other at all. The
|
|
868
|
+
body is written one line per event in order, so position within a kind is exactly
|
|
869
|
+
the pairing.
|
|
831
870
|
"""
|
|
832
|
-
out = {}
|
|
871
|
+
out: dict = {}
|
|
833
872
|
for line in message.splitlines():
|
|
834
873
|
line = line.strip()
|
|
835
874
|
if _TRAILER.match(line):
|
|
836
875
|
continue
|
|
837
876
|
head, _, rest = line.partition(" ")
|
|
838
|
-
if head not in kinds
|
|
877
|
+
if head not in kinds:
|
|
839
878
|
continue
|
|
840
879
|
fields = {}
|
|
841
880
|
for chunk in rest.split(SEP):
|
|
842
881
|
key, sign, value = chunk.partition("=")
|
|
843
882
|
if sign and key.strip():
|
|
844
883
|
fields[key.strip()] = value.strip()
|
|
845
|
-
out[
|
|
884
|
+
out.setdefault(head, []).append(fields)
|
|
846
885
|
return out
|
|
847
886
|
|
|
848
887
|
|
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
|
|
@@ -483,7 +482,13 @@ def _coverage_lint(root: Path, s: dict) -> list:
|
|
|
483
482
|
return warns
|
|
484
483
|
|
|
485
484
|
covered: dict = {}
|
|
486
|
-
|
|
485
|
+
# The cuts that have LEFT the board carry evidence too — a criterion delivered
|
|
486
|
+
# by a task in a shipped cut is delivered. Reading the live board alone turned
|
|
487
|
+
# filing a cut into a permanent warning about every AC it delivered, with no
|
|
488
|
+
# way left to satisfy it.
|
|
489
|
+
filed = [task for v in scan_filed(root) for task in v.all_tasks()]
|
|
490
|
+
for t in ([task for v in s["versions"] for task in v.all_tasks()]
|
|
491
|
+
+ s["backlog"] + filed):
|
|
487
492
|
if t.status != "complete" or not t.covers:
|
|
488
493
|
continue
|
|
489
494
|
if t.owner:
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import subprocess
|
|
3
1
|
import sys
|
|
4
2
|
from pathlib import Path
|
|
5
3
|
|
|
6
|
-
from .tree import find_work_root
|
|
7
|
-
from .frontmatter import _eol, read_item, split_frontmatter, write_item
|
|
4
|
+
from .tree import find_work_root
|
|
8
5
|
from .model import Task, _ordered, scan, scan_shipped
|
|
9
6
|
from .lint import lint_warnings
|
|
10
|
-
from .generate import _regen_readme,
|
|
7
|
+
from .generate import _regen_readme, settle_epic_tier
|
|
11
8
|
from .align import (_align_acceptance, _align_agents, _align_citations,
|
|
12
9
|
_align_definitions, _align_domains, _align_hosts,
|
|
13
10
|
_align_ledger_index, _align_retired, _align_single_feature,
|
|
@@ -192,52 +189,3 @@ def cmd_align(args) -> int:
|
|
|
192
189
|
print(f"\n {len(warns)} warning(s) · {len(errors)} error(s){tail} · exit 0 "
|
|
193
190
|
f"(report-only — the flip to blocking is its own task)")
|
|
194
191
|
return 0
|
|
195
|
-
def cmd_migrate_owner(args) -> int:
|
|
196
|
-
"""Mechanical, one-way: `product:` becomes `owner:`, and the retired `infra`
|
|
197
|
-
sentinel becomes the `operations` domain. Skips files with uncommitted
|
|
198
|
-
changes unless --force: the tree is shared, and silently rewriting another
|
|
199
|
-
session's open edit is how a rename eats work that was never committed."""
|
|
200
|
-
root = find_work_root()
|
|
201
|
-
repo = root.parent
|
|
202
|
-
dirty = set()
|
|
203
|
-
try:
|
|
204
|
-
out = subprocess.run(["git", "status", "--porcelain"], cwd=repo,
|
|
205
|
-
capture_output=True, text=True, check=True).stdout
|
|
206
|
-
for line in out.splitlines():
|
|
207
|
-
if len(line) > 3:
|
|
208
|
-
dirty.add((repo / line[3:].strip().strip('"')).resolve())
|
|
209
|
-
except Exception:
|
|
210
|
-
pass # not a git repo, or git unavailable — migrate everything
|
|
211
|
-
|
|
212
|
-
def _is_dirty(md: Path) -> bool:
|
|
213
|
-
"""Git reports an untracked DIRECTORY as the directory, not its files, so
|
|
214
|
-
a plain membership test misses `work/.../new-task/task.md` inside a folder
|
|
215
|
-
another session just created. Match the path or any parent."""
|
|
216
|
-
p = md.resolve()
|
|
217
|
-
return any(d == p or d in p.parents for d in dirty)
|
|
218
|
-
|
|
219
|
-
changed, skipped = [], []
|
|
220
|
-
for md in sorted(root.rglob("task.md")):
|
|
221
|
-
text = read_item(md)
|
|
222
|
-
fm, body = split_frontmatter(text)
|
|
223
|
-
if fm is None or not re.search(r"^product:", fm, re.MULTILINE):
|
|
224
|
-
continue
|
|
225
|
-
if _is_dirty(md) and not args.get("force"):
|
|
226
|
-
skipped.append(rel(md, root))
|
|
227
|
-
continue
|
|
228
|
-
# Only the KEY is rewritten — the value and its spacing are untouched, so
|
|
229
|
-
# 105 files change one word each instead of churning frontmatter layout.
|
|
230
|
-
new_fm = re.sub(r"^product:", "owner:", fm, flags=re.MULTILINE)
|
|
231
|
-
eol = _eol(text)
|
|
232
|
-
write_item(md, f"---{eol}{new_fm}{eol}---{eol}{body}")
|
|
233
|
-
changed.append(rel(md, root))
|
|
234
|
-
|
|
235
|
-
print(f"renamed product: -> owner: in {len(changed)} task.md")
|
|
236
|
-
if skipped:
|
|
237
|
-
print(f"SKIPPED {len(skipped)} with uncommitted changes (another session "
|
|
238
|
-
f"may be editing them) — rerun after they land, or pass --force:")
|
|
239
|
-
for f in skipped:
|
|
240
|
-
print(f" {f}")
|
|
241
|
-
if changed:
|
|
242
|
-
_sync(root)
|
|
243
|
-
return 0
|
|
@@ -106,10 +106,21 @@ def cmd_id_new(args) -> int:
|
|
|
106
106
|
# Walk forward rather than retrying the same number: a loser in the race wants
|
|
107
107
|
# the NEXT number, and a leftover lock from a crashed session must not wedge the
|
|
108
108
|
# allocator forever.
|
|
109
|
+
#
|
|
110
|
+
# THE SCAN IS REPEATED UNDER THE CLAIM, and that is what makes the lock work at
|
|
111
|
+
# all. The scan above is slow and happens outside it; the lock is released the
|
|
112
|
+
# moment the heading is written. So a second session that scanned before the
|
|
113
|
+
# first one wrote, and claimed after it released, found the number free twice
|
|
114
|
+
# and both wrote the same `### D-nn`. Re-deriving here costs one extra tree read
|
|
115
|
+
# on the one command that allocates, and it is the only moment at which the
|
|
116
|
+
# question "is this number still free" can be asked and acted on atomically.
|
|
109
117
|
for candidate in range(n, n + 1000):
|
|
110
|
-
if _claim(root, candidate):
|
|
118
|
+
if not _claim(root, candidate):
|
|
119
|
+
continue
|
|
120
|
+
if candidate >= _next_free(root):
|
|
111
121
|
n = candidate
|
|
112
122
|
break
|
|
123
|
+
_release(root, candidate)
|
|
113
124
|
else: # pragma: no cover — defensive
|
|
114
125
|
die("could not claim an id after 1000 attempts — check .work/ids/")
|
|
115
126
|
|
package/harness/harness/shard.py
CHANGED
|
@@ -14,6 +14,12 @@ from pathlib import Path
|
|
|
14
14
|
from typing import NamedTuple
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
#: Where runners drop their shards, from `coverage.shard` — set by `config.apply()`,
|
|
18
|
+
#: the same way `coverage.VERIFY` is. A module global rather than an import, because
|
|
19
|
+
#: this module is imported by three readers and importing config from here would
|
|
20
|
+
#: close a cycle.
|
|
21
|
+
DIR = ".work/coverage"
|
|
22
|
+
|
|
17
23
|
#: Precedence when several sites claim one criterion — the SAME rank both
|
|
18
24
|
#: reporters already apply within a single runner. Anything unrecognised ranks
|
|
19
25
|
#: below `passed`, so a shard that learns a new status can never silently
|
|
@@ -72,7 +78,7 @@ def _load_run(repo: Path) -> Run:
|
|
|
72
78
|
runners separately is exactly the drift this module ended.
|
|
73
79
|
"""
|
|
74
80
|
read = Run({}, {}, [], [], [])
|
|
75
|
-
d = repo /
|
|
81
|
+
d = repo / DIR
|
|
76
82
|
if not d.is_dir():
|
|
77
83
|
return read
|
|
78
84
|
for p in sorted(d.glob("*.json")):
|
package/harness/harness/shift.py
CHANGED
|
@@ -56,9 +56,15 @@ def read_claim(folder: Path):
|
|
|
56
56
|
try:
|
|
57
57
|
data = json.loads(p.read_text())
|
|
58
58
|
expires = datetime.fromisoformat(str(data["expires"]))
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
# TypeError is in the list because the comparison below throws it, not the
|
|
60
|
+
# parse: a claim written without a timezone reads fine and then cannot be
|
|
61
|
+
# compared against an aware `now`. That raised out of a function whose whole
|
|
62
|
+
# contract is that an unreadable claim reads as absent — so a single
|
|
63
|
+
# malformed `.claim` took down every command that asks who holds an item,
|
|
64
|
+
# which is the fail-closed lock this was written to avoid being.
|
|
65
|
+
if expires <= _now():
|
|
66
|
+
return None
|
|
67
|
+
except (OSError, TypeError, ValueError, KeyError, json.JSONDecodeError):
|
|
62
68
|
return None
|
|
63
69
|
return data
|
|
64
70
|
|
package/harness/harness/task.py
CHANGED
|
@@ -16,7 +16,7 @@ from .scaffold import _check_kebab, _check_owner_ref, _check_priority, _check_un
|
|
|
16
16
|
from .epic import _bucketed, cmd_epic_move, epic_for_task
|
|
17
17
|
from .generate import _sync
|
|
18
18
|
from .gate import delivery_gate, report_gate
|
|
19
|
-
from .autonomy import derive_tier
|
|
19
|
+
from .autonomy import _open_questions, derive_tier
|
|
20
20
|
from . import events
|
|
21
21
|
|
|
22
22
|
#: Where this repo keeps approved plan files, from `plans.dir` in config. None means
|
|
@@ -276,14 +276,20 @@ def cmd_move(args) -> int:
|
|
|
276
276
|
to = args["status"]
|
|
277
277
|
if to not in BUCKETS:
|
|
278
278
|
die(f"status must be one of {', '.join(BUCKETS)}")
|
|
279
|
-
if to == BLOCKED:
|
|
280
|
-
die(f"`move {name} blocked` is not how a task blocks — a blocked task "
|
|
281
|
-
f"without a recorded question is one nobody can unblock. Use "
|
|
282
|
-
f"`jarvis work ask {name} --question \"…\"`.")
|
|
283
279
|
|
|
284
280
|
task = locate(root, name)
|
|
285
281
|
if not task:
|
|
286
282
|
die(missing(root, name))
|
|
283
|
+
# The rule is right and the test used to be the wrong one: it asked whether you
|
|
284
|
+
# had just called `ask`, rather than whether the item has a question nobody has
|
|
285
|
+
# answered. So a task carrying its question in its own frontmatter — showing in
|
|
286
|
+
# `needs` at that moment — could not be put back into `blocked/` after somebody
|
|
287
|
+
# moved it out, which is exactly what a session does when it picks one up and
|
|
288
|
+
# finds the question still open.
|
|
289
|
+
if to == BLOCKED and not _open_questions(task):
|
|
290
|
+
die(f"`move {name} blocked` is not how a task blocks — a blocked task "
|
|
291
|
+
f"without a recorded question is one nobody can unblock. Use "
|
|
292
|
+
f"`jarvis work ask {name} --question \"…\"`.")
|
|
287
293
|
if task.in_backlog:
|
|
288
294
|
die(f"'{name}' is in backlog — pull it into a version first "
|
|
289
295
|
f"(jarvis work place {name} --version <v>)")
|