@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 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 = "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
- 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
 
@@ -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:
@@ -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
 
@@ -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:
@@ -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, rel
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, _sync, settle_epic_tier
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
 
@@ -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 / ".work" / "coverage"
81
+ d = repo / DIR
76
82
  if not d.is_dir():
77
83
  return read
78
84
  for p in sorted(d.glob("*.json")):
@@ -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
- except (OSError, ValueError, KeyError, json.JSONDecodeError):
60
- return None
61
- if expires <= _now():
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
 
@@ -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>)")