@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.
@@ -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(f"no task named '{name}'")
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
- 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:
@@ -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(f"no task named '{name}' found")
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(f"no task named '{name}' found")
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) < 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
 
@@ -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 — `.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 "
@@ -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, "unlevelled": 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
- rows.append((name, len(built), len(proven), len(eyes),
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['declared'] - t['built']} of {t['declared']} promises. "
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")
@@ -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
- rewrite_file(
197
- md,
198
- lambda d: d.update({"updated": date.today().isoformat()}),
199
- EPIC_FM_ORDER,
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
- 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:
@@ -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(f"no task named '{name}' found")
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(f"no task named '{name}' found")
533
+ die(missing(root, name))
534
534
  entry = f"{date.today().isoformat()} {ac} {saw}"
535
535
  md = task.folder / "task.md"
536
536
 
@@ -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
 
@@ -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 the session had
665
- uncommitted work open, and git declines to rebase over it. That reads as a scary
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
- 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")
674
- return f"the branch moved and the rebase onto it did not apply: {_tail(err)}"
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
- e.update(details.get(kind) or {})
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 payload, matched back to its event by name.
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 or not rest.strip() or head in out:
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[head] = fields
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(f"no task named '{args['name']}' found")
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
@@ -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
@@ -483,7 +482,13 @@ def _coverage_lint(root: Path, s: dict) -> list:
483
482
  return warns
484
483
 
485
484
  covered: dict = {}
486
- for t in [task for v in s["versions"] for task in v.all_tasks()] + s["backlog"]:
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: