@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 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 = "797d33f";
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
- The floor comes from the tree, so it cannot be forgotten the way a hand-set
45
- field can; `given` may raise it and is refused if it tries to lower it. Returns
46
- `(tier, why)` `why` is printed on refusal, because a floor that will not say
47
- which signal set it is a floor people argue with.
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, why = 3, f"owner: {owner_root} — this domain is always tier 3"
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 floor, why
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 == floor else f"raised from {floor} ({why})")
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) < 3:
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 when > prior["when"]:
100
- seen[branch] = {"branch": branch, "when": when, "by": who, "sha": sha[:12]}
101
- return sorted(seen.values(), key=lambda b: b["when"], reverse=True)
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
- code, out, _ = git._git(repo, "for-each-ref",
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 Path(p).is_absolute() or ".." in Path(p).parts:
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
- if args["_verb"] != "unset" or not _present(raw, parts):
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 — `.work/coverage/` is empty.\n"
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
- rows, totals = [], {"declared": 0, "built": 0, "proven": 0, "failed": 0,
93
- "todo": 0, "gap": 0, "ahead": 0, "eyes": 0, "unlevelled": 0}
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(provable)
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
- rows.append((name, len(built), len(proven), len(eyes),
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
- rate = f"{100 * t['proven'] // t['built']}%" if t["built"] else "—"
184
- print(f"\n BUILT COVERAGE {t['proven']}/{t['built']} = {rate} "
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: {t['eyes']}. A look is a level, not an\n"
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: {t['declared'] - t['built']} of {t['declared']} promises. "
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")
@@ -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
- md = dest / "epic.md"
196
- rewrite_file(
197
- md,
198
- lambda d: d.update({"updated": date.today().isoformat()}),
199
- EPIC_FM_ORDER,
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
- if kind not in KINDS:
95
- raise ValueError(f"unknown event kind '{kind}' one of {', '.join(KINDS)}")
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 HOLDS:
119
+ if kind not in UNCOMMITTED:
106
120
  _PENDING.append(entry)
107
121
  return
108
122
  try:
@@ -78,15 +78,22 @@ def _flat(value) -> str:
78
78
  return str(value).replace("\n", " ").replace("\r", " ").replace(SEP, " - ")
79
79
 
80
80
 
81
- #: Commands that read the origin before they act. Performance only: what gets
82
- #: COMMITTED is driven by what actually changed, so a name missing from this set
83
- #: costs a stale pre-pull and can never cost a write. `sync` is absent because it
84
- #: refreshes on its own, and being in both places is two fetches for one command.
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", "epic-new", "feature-new", "version-new", "domain-new", "system-new",
87
- "place", "move", "handoff", "plan", "session", "release", "archive",
88
- "next", "drop", "ask", "answer", "verify", "observed", "id-new", "readme",
89
- "migrate-owner",
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 the session had
665
- uncommitted work open, and git declines to rebase over it. That reads as a scary
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
- return ("the branch moved, and your own uncommitted edits are in the way of "
672
- "rebasing onto it nothing was moved or stashed; commit them and the "
673
- "next board write pushes both")
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(details.get(kind) or {})
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 payload, matched back to its event by name.
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 or not rest.strip() or head in out:
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[head] = fields
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 first real complaint. The FIRST line, not the last: git follows a
890
- `fatal:` with a paragraph of advice, and the closing line of that paragraph
891
- ("…and the repository exists.") reads as gibberish on its own."""
892
- for line in (text or "").splitlines():
893
- line = line.strip()
894
- if line and not line.startswith("hint:"):
895
- return line[:200]
896
- return "no reason given"
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"
@@ -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:` (`jarvis work migrate-owner` does it)")
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
- for t in [task for v in s["versions"] for task in v.all_tasks()] + s["backlog"]:
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 scan_features(root):
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: