@appchy/jarvis 0.1.83 → 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 +6 -2
- package/dist/bin.js.map +1 -1
- package/harness/harness/architecture.py +2 -2
- package/harness/harness/autonomy.py +23 -11
- 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/gate.py +3 -3
- package/harness/harness/git.py +58 -19
- package/harness/harness/kickoff.py +2 -2
- package/harness/harness/lint.py +9 -4
- package/harness/harness/model.py +84 -5
- 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 +11 -5
- package/harness/harness/task.py +30 -18
- package/harness/test_work.py +777 -1
- package/harness/work.py +38 -51
- package/package.json +2 -2
|
@@ -2,7 +2,7 @@ from pathlib import Path
|
|
|
2
2
|
|
|
3
3
|
from . import ids
|
|
4
4
|
from .tree import BACKLOG_END, BACKLOG_START, assets_dir, die, find_work_root, rel
|
|
5
|
-
from .model import locate
|
|
5
|
+
from .model import locate, missing
|
|
6
6
|
from .registry import (DOMAIN_ORDER, _citation_counts, definition_sites,
|
|
7
7
|
domains_for_owner, hosts, locate_domain, locate_system,
|
|
8
8
|
scan_systems, systems_for_code)
|
|
@@ -248,7 +248,7 @@ def cmd_rules(args) -> int:
|
|
|
248
248
|
die("usage: jarvis work rules --task <name>")
|
|
249
249
|
t = locate(root, name)
|
|
250
250
|
if not t:
|
|
251
|
-
die(
|
|
251
|
+
die(missing(root, name))
|
|
252
252
|
systems = systems_for_code(root, t.code) if t.code else []
|
|
253
253
|
domains = domains_for_owner(root, t.owner)
|
|
254
254
|
if not systems and not domains:
|
|
@@ -28,7 +28,7 @@ from datetime import date
|
|
|
28
28
|
from .tree import (BLOCKED, BUCKETS, DEFAULT_AUTONOMY_CEILING, TIER3_OWNERS,
|
|
29
29
|
TIER_MEANING, TIERS, die, find_work_root, rel)
|
|
30
30
|
from .frontmatter import as_list, parse_frontmatter, rewrite_file
|
|
31
|
-
from .model import locate, record_session, scan
|
|
31
|
+
from .model import locate, missing, record_session, scan
|
|
32
32
|
from .generate import _sync
|
|
33
33
|
from . import events, links
|
|
34
34
|
|
|
@@ -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:
|
|
@@ -129,7 +141,7 @@ def cmd_ask(args) -> int:
|
|
|
129
141
|
"[--owner founder] [--durable]")
|
|
130
142
|
task = locate(root, name)
|
|
131
143
|
if not task:
|
|
132
|
-
die(
|
|
144
|
+
die(missing(root, name))
|
|
133
145
|
if task.in_backlog:
|
|
134
146
|
die(f"'{name}' is in backlog — a backlog task has no status to park. "
|
|
135
147
|
f"Pull it into a version first.")
|
|
@@ -195,7 +207,7 @@ def cmd_answer(args) -> int:
|
|
|
195
207
|
die("usage: jarvis work answer <task> --choose \"…\"")
|
|
196
208
|
task = locate(root, name)
|
|
197
209
|
if not task:
|
|
198
|
-
die(
|
|
210
|
+
die(missing(root, name))
|
|
199
211
|
open_qs = _open_questions(task)
|
|
200
212
|
if not open_qs:
|
|
201
213
|
die(f"'{name}' has no open question — nothing to answer")
|
|
@@ -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/gate.py
CHANGED
|
@@ -33,7 +33,7 @@ from pathlib import Path
|
|
|
33
33
|
|
|
34
34
|
from .tree import die, find_work_root
|
|
35
35
|
from .frontmatter import as_list, parse_frontmatter, rewrite_file, split_frontmatter
|
|
36
|
-
from .model import locate
|
|
36
|
+
from .model import locate, missing
|
|
37
37
|
from .shard import _load_run
|
|
38
38
|
from . import events, git, peers
|
|
39
39
|
from .autonomy import tier_of
|
|
@@ -277,7 +277,7 @@ def _execute(root, name) -> tuple:
|
|
|
277
277
|
_write_run(root, data)
|
|
278
278
|
|
|
279
279
|
if name and not _record(root, name, results, ok, sha):
|
|
280
|
-
die(
|
|
280
|
+
die(missing(root, name))
|
|
281
281
|
return results, ok, sha
|
|
282
282
|
|
|
283
283
|
|
|
@@ -530,7 +530,7 @@ def cmd_observed(args) -> int:
|
|
|
530
530
|
die(f"--ac must look like 'AC-01' (got '{ac}')")
|
|
531
531
|
task = locate(root, name)
|
|
532
532
|
if not task:
|
|
533
|
-
die(
|
|
533
|
+
die(missing(root, name))
|
|
534
534
|
entry = f"{date.today().isoformat()} {ac} {saw}"
|
|
535
535
|
md = task.folder / "task.md"
|
|
536
536
|
|
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
|
|
|
@@ -23,7 +23,7 @@ assuming a fresh session infers it.
|
|
|
23
23
|
"""
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
|
-
from .model import locate
|
|
26
|
+
from .model import locate, missing
|
|
27
27
|
from .tree import cli, die, find_work_root, rel
|
|
28
28
|
|
|
29
29
|
#: `graph.mcp`, `session.mcp`, `session.tool`, `spine.*`, `instructions.skill` —
|
|
@@ -182,7 +182,7 @@ def cmd_kickoff(args) -> int:
|
|
|
182
182
|
root = find_work_root()
|
|
183
183
|
task = locate(root, args["name"])
|
|
184
184
|
if not task:
|
|
185
|
-
die(
|
|
185
|
+
die(missing(root, args["name"]))
|
|
186
186
|
prompt = build_prompt(task, root, args.get("next"), args.get("watch"))
|
|
187
187
|
|
|
188
188
|
# `--prompt-only` is the pipe. Everything else this command prints is
|
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:
|