@appchy/jarvis 0.1.128 → 0.1.130

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.
@@ -359,13 +359,10 @@ def cmd_digest(args) -> int:
359
359
  answered = names("answered")
360
360
  refused = [e for e in rows if e["event"] == "gate-refused"]
361
361
  released = names("released")
362
- # What was WORKED ON is a task moving into in-progress, not a lease being taken.
363
- # A claim is one way to arrive there and it is not the only one — a person who
364
- # moved three tasks by hand read as an idle shift, and under git a claim is not
365
- # recorded at all, because a hold belongs to a machine rather than to a branch.
362
+ # What was WORKED ON is a task moving into in-progress the take itself, whoever
363
+ # made it, a shift or a person moving it by hand.
366
364
  worked = sorted({e["name"] for e in rows
367
- if e["event"] == "claimed"
368
- or (e["event"] == "moved" and e.get("to") == "in-progress")})
365
+ if e["event"] == "moved" and e.get("to") == "in-progress"})
369
366
 
370
367
  print(f"DIGEST {len(rows)} event(s)"
371
368
  + (f" since {since}" if since else "") + "\n")
@@ -176,10 +176,6 @@ DEFAULTS = {
176
176
  # default: a harness that ships with no ceiling is a harness whose safety
177
177
  # depends on nobody having configured it.
178
178
  "ceiling": 2,
179
- # How long an instance's claim on a task is good for. Long enough that real
180
- # work finishes inside it; short enough that a crashed shift's task is
181
- # takeable the same day.
182
- "lease_minutes": 240,
183
179
  },
184
180
  "graph": {
185
181
  # The MCP server serving this repo's governance↔code graph, or null. Null is
@@ -466,9 +462,6 @@ def _validate(cfg: dict) -> None:
466
462
  if not isinstance(ceiling, int) or isinstance(ceiling, bool) or not 0 <= ceiling <= 3:
467
463
  raise ConfigError("autonomy.ceiling must be an integer 0–3 (the highest "
468
464
  "task tier an unattended run may act on alone)")
469
- lease = cfg["autonomy"]["lease_minutes"]
470
- if not isinstance(lease, int) or isinstance(lease, bool) or lease < 1:
471
- raise ConfigError("autonomy.lease_minutes must be a positive integer")
472
465
  mcp = cfg["graph"]["mcp"]
473
466
  if mcp is not None and not isinstance(mcp, str):
474
467
  raise ConfigError("graph.mcp must be a server name or null")
@@ -657,7 +650,6 @@ def apply(cfg: dict) -> None:
657
650
  gate.VERIFY = dict(cfg["verify"])
658
651
  task.PLANS_DIR = cfg["plans"]["dir"]
659
652
  autonomy.CEILING = cfg["autonomy"]["ceiling"]
660
- shift.LEASE_MINUTES = cfg["autonomy"]["lease_minutes"]
661
653
  # `shift` reads the ceiling it was imported with, so bind it there too — a
662
654
  # module-level `from … import CEILING` captures the value, not the name.
663
655
  shift.CEILING = cfg["autonomy"]["ceiling"]
@@ -1,10 +1,13 @@
1
1
  """The record of what the harness DID — a file, or the commits themselves.
2
2
 
3
- **History, never state.** Nothing in the harness reads this record to decide what is
4
- true now: status is the bucket, a version's status is derived from its tasks, a
5
- rule lives in the domain that owns it. That line is the whole design, and blurring
6
- it would rebuild the central ledger `P-1` dissolved — one file that answers
7
- everything, which is one file to disagree with the tree.
3
+ **History, never state with one answer derived from it.** Status is the bucket, a
4
+ version's status is derived from its tasks, a rule lives in the domain that owns it,
5
+ and none of that is read from here: blurring that line would rebuild the central
6
+ ledger `P-1` dissolved — one file that answers everything, which is one file to
7
+ disagree with the tree. The one exception is **who is on an item**. A take IS an
8
+ event, the commit that records it is the only record every machine shares, and the
9
+ lease that answered the question separately was a second record that disagreed — so
10
+ `holders` derives it from here, and nothing else does.
8
11
 
9
12
  **Two backends, and which one is the repo's call.** With `git.commit` off — the
10
13
  default, and what every consumer that has not asked for anything else gets — the
@@ -15,11 +18,10 @@ writes to git therefore has no `.events.jsonl` at all, which is the point — th
15
18
  file records a session id rather than a machine or a branch, and it appends at the
16
19
  end, so two active branches conflict on it every time.
17
20
 
18
- Two kinds of event never reach git. A **hold** (`claimed`, `released-claim`) is
19
- coordination, and coordination that replicates is two machines holding one item with
20
- neither knowing. A **refusal** changed nothing, so there is no commit for it to ride
21
- on and no board history for it to be part of; `status` loses its "refused twice"
22
- warning under git, which is a deliberate trade rather than an oversight.
21
+ One kind of event never reaches git: a **refusal** changed nothing, so there is no
22
+ commit for it to ride on and no board history for it to be part of; `status` loses
23
+ its "refused twice" warning under git, which is a deliberate trade rather than an
24
+ oversight.
23
25
 
24
26
  What it buys is the thing a tree cannot hold: *time*. A tree remembers only now, so
25
27
  it cannot say what shipped overnight, how long something sat blocked, or which
@@ -44,18 +46,13 @@ from . import git
44
46
 
45
47
  LOG = ".events.jsonl"
46
48
 
47
- #: Events about a hold rather than about the board. They never enter git — not as a
48
- #: file, not as a trailer because a claim belongs to a machine and a moment, and a
49
- #: replicated one is a claim two endpoints both believe they have.
50
- HOLDS = ("claimed", "released-claim")
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",)
49
+ #: Events no commit ever carries: a REFUSAL, which changed nothing, so there is no
50
+ #: commit for it to ride on. Buffering a refusal made the git seam see work waiting
51
+ #: for a commit while the tree was untouched, and report the change as "carried by a
52
+ #: board write that committed a moment earlier": a refused completion, told its work
53
+ #: was safely in git under somebody else's commit, when nothing had been written and
54
+ #: nothing committed.
55
+ UNCOMMITTED = ("gate-refused",)
59
56
 
60
57
  #: What this command has done so far, waiting for the commit that carries it. Only
61
58
  #: used under git; a process runs one command, so one buffer is one commit.
@@ -70,8 +67,6 @@ KINDS = (
70
67
  "blocked", # parked, waiting on a human
71
68
  "asked", # a question was filed against a task
72
69
  "answered", # and resolved
73
- "claimed", # an instance leased a task
74
- "released-claim", # ...and gave it back, or the lease expired
75
70
  "verified", # verify.* ran — with its exit codes
76
71
  "observed", # an eyes-on record against an AC
77
72
  "gate-refused", # completion was refused, and why
@@ -134,11 +129,15 @@ def pending() -> list:
134
129
  return list(_PENDING)
135
130
 
136
131
 
137
- def read(root: Path, since: str = "", name: str = "") -> list:
132
+ def read(root: Path, since: str = "", name: str = "", eventless: bool = False) -> list:
138
133
  """Every event, oldest first. A malformed line is skipped silently — a
139
- half-written record from a killed process must not break `digest`."""
134
+ half-written record from a killed process must not break `digest`.
135
+
136
+ `eventless` also returns, under git, each commit that recorded a run on an item
137
+ without recording an event — what taking an item already in progress writes. The
138
+ holder derivation asks for it; the activity log never shows one."""
140
139
  if git.enabled():
141
- return git.read(root.parent, since=since, name=name)
140
+ return git.read(root.parent, since=since, name=name, eventless=eventless)
142
141
  p = root / LOG
143
142
  if not p.is_file():
144
143
  return []
@@ -43,8 +43,8 @@ from .autonomy import tier_of
43
43
  VERIFY: dict = {}
44
44
 
45
45
  #: The run in flight, beside the board it is proving. Never enters git — it names a
46
- #: pid on one machine at one moment, exactly like `.claim`, and `git.py` drops it by
47
- #: filename for the same reason.
46
+ #: pid on one machine at one moment, and `git.py` drops it by filename for that
47
+ #: reason.
48
48
  RUN = ".verify"
49
49
 
50
50
 
@@ -47,6 +47,10 @@ def _version_section(v: Version, root: Path) -> str:
47
47
  }.get(status, status)
48
48
  link = f"[{v.title}]({_relpath_from_readme(v.md, root)})"
49
49
  head = f"### Version — {link} _( {badge} )_"
50
+ over = v.over_ceiling()
51
+ if over:
52
+ head += (f"\n\n**Over its ceiling by {over}** — {len(v.open_tasks())} open; "
53
+ f"a reshape is owed before it closes.")
50
54
  if v.outcome:
51
55
  head += f"\n\n{v.outcome}"
52
56
  parts = []
@@ -27,10 +27,9 @@ Four things read that format, so it is a rule rather than a convention — see t
27
27
  repo's own architecture docs. Who, when and which branch are NOT trailers: git
28
28
  already knows them, and a second copy is a second thing to drift.
29
29
 
30
- **Coordination never enters git.** A `.claim` belongs to a machine and a moment, not
31
- to a branch, and a `.verify` naming the pid running this checkout's gates is the same
32
- kind of fact. Both are filtered out of every commit here, whatever the configured
33
- paths say.
30
+ **A run in flight never enters git.** A `.verify` names the pid running this
31
+ checkout's gates — a fact about one machine at one moment, not about a branch so it
32
+ is filtered out of every commit here, whatever the configured paths say.
34
33
 
35
34
  **What a commit contains, exactly.** Only the configured paths, committed with a
36
35
  pathspec, so a session's unrelated staged code is neither swept in nor disturbed.
@@ -56,13 +55,10 @@ GIT: dict = {"commit": False, "push": False, "remote": "origin", "paths": ["work
56
55
  #: format that drifts breaks the record silently rather than loudly.
57
56
  ITEM, EVENT, MACHINE, SESSION = "Work-Item", "Work-Event", "Work-Machine", "Work-Session"
58
57
 
59
- #: Never committed, whatever `git.paths` says. A claim is coordination: it lives on
60
- #: the machine that granted it, for as long as it is good for, and a replicated one
61
- #: is two machines holding the same item with neither knowing. A verify run is the
62
- #: same kind of fact — a pid on one box, true for the minutes it takes — and a
63
- #: committed one would tell every other checkout a build is running inside it.
64
- CLAIM = ".claim"
65
- LOCAL = frozenset({CLAIM, ".verify"})
58
+ #: Never committed, whatever `git.paths` says. A verify run is a fact about one box
59
+ #: at one moment a pid, true for the minutes it takes and a committed one would
60
+ #: tell every other checkout a build is running inside it.
61
+ LOCAL = frozenset({".verify"})
66
62
 
67
63
  _NET_TIMEOUT = 120
68
64
  _ATTEMPTS = 3
@@ -259,7 +255,7 @@ def _hold(repo):
259
255
  """Serialise the commit against other board writes on this machine.
260
256
 
261
257
  In the git directory rather than the work tree, because it is machine-local
262
- coordination and belongs where `.claim` belongs — nowhere near a branch. A lock
258
+ coordination and belongs on this machine — nowhere near a branch. A lock
263
259
  that cannot be taken is WAITED for and then ignored: the commit is the thing
264
260
  that matters, and blocking a board write forever to protect a lock would be the
265
261
  lock winning over the work it exists to protect.
@@ -860,7 +856,7 @@ def _session() -> str:
860
856
  _FORMAT = "%x00%H%x1f%aI%x1f%an%x1f%B"
861
857
 
862
858
 
863
- def _log(repo, *extra) -> list:
859
+ def _log(repo, *extra, eventless=False) -> list:
864
860
  """Board events out of `git log`, oldest first. One parser for every caller, so
865
861
  what `digest` reads and what a pull reports can never be two different shapes."""
866
862
  code, out, _ = _git(repo, "log", "--reverse", f"--format={_FORMAT}", "-E",
@@ -870,22 +866,26 @@ def _log(repo, *extra) -> list:
870
866
  rows = []
871
867
  for record in out.split("\0"):
872
868
  if record.strip():
873
- rows.extend(_events(record))
869
+ rows.extend(_events(record, eventless))
874
870
  return rows
875
871
 
876
872
 
877
- def read(repo, since: str = "", name: str = "") -> list:
873
+ def read(repo, since: str = "", name: str = "", eventless: bool = False) -> list:
878
874
  """The board's history, oldest first, read back out of the commits.
879
875
 
880
876
  Local: `git log` reads this clone and nothing else, so history costs no network
881
877
  and has no rate limit. There is no index and no cache — a fact that can be
882
878
  derived is not stored, and this one can.
879
+
880
+ `eventless` also returns each commit that recorded a run on an item without
881
+ recording an event, as a row whose `event` is empty — see `_events`.
883
882
  """
884
- rows = _log(repo, *([f"--since={_since(since)}"] if since else []))
883
+ rows = _log(repo, *([f"--since={_since(since)}"] if since else []),
884
+ eventless=eventless)
885
885
  return [r for r in rows if not name or r.get("name") == name]
886
886
 
887
887
 
888
- def _events(record: str) -> list:
888
+ def _events(record: str, eventless: bool = False) -> list:
889
889
  """Every event one commit carried, back in the shape the readers expect."""
890
890
  parts = record.split("\x1f")
891
891
  if len(parts) < 4:
@@ -894,11 +894,22 @@ def _events(record: str) -> list:
894
894
  trailers = _trailers(message)
895
895
  item = (trailers.get(ITEM) or [""])[0]
896
896
  kinds = trailers.get(EVENT) or []
897
- if not item or not kinds:
898
- return []
899
- details = _details(message, kinds)
900
897
  who = (trailers.get(SESSION) or [""])[0]
901
898
  host = (trailers.get(MACHINE) or [""])[0]
899
+ if not item:
900
+ return []
901
+ if not kinds:
902
+ # Absent from the activity log on purpose (see `_message`), and handed only to
903
+ # a reader that asks: recording a run on an item already in progress writes
904
+ # exactly this commit, and to the question of who is on an item it is a take.
905
+ if not (eventless and who):
906
+ return []
907
+ row = {"ts": _utc(when), "event": "", "name": item, "sha": sha[:12],
908
+ "author": author, "by": who}
909
+ if host:
910
+ row["machine"] = host
911
+ return [row]
912
+ details = _details(message, kinds)
902
913
  out = []
903
914
  # One pool per kind, drawn from in order, so the second `verified` in a commit
904
915
  # gets the second body line rather than the first's.
@@ -0,0 +1,173 @@
1
+ {
2
+ "why": [
3
+ "Who is on an item is derived from commit trailers, by two readers in two languages: the harness",
4
+ "(`harness/holders.py`, over what `git._events` parses) and the board (`packages/board/src/holders.ts`,",
5
+ "over what `board.git.ts` reads). Two parsers and two derivations of one answer is exactly where the",
6
+ "terminal and the board come to name different holders for the same item, so both suites run THESE",
7
+ "commits and assert THIS answer. A drift is a red test in both.",
8
+ "",
9
+ "Each case is an item's board history, oldest first, as commits. `expect` maps an item to its holders",
10
+ "in order: the session that took it (or, with none, who git says made the commit), the machine, and",
11
+ "which holder that take followed, when it followed one."
12
+ ],
13
+ "cases": [
14
+ {
15
+ "name": "a move into progress is a take",
16
+ "commits": [
17
+ {
18
+ "sha": "a1",
19
+ "when": "2026-09-12T10:00:00+03:00",
20
+ "author": "Danny",
21
+ "message": "docs(work): moved thing\n\nmoved from=queue · to=in-progress\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
22
+ }
23
+ ],
24
+ "expect": {
25
+ "thing": [
26
+ { "session": "11111111-1111-4111-8111-111111111111", "machine": "box-a", "tookOverFrom": null }
27
+ ]
28
+ }
29
+ },
30
+ {
31
+ "name": "creating an item, verifying it and moving it elsewhere are its history, not a claim on it",
32
+ "commits": [
33
+ {
34
+ "sha": "b1",
35
+ "when": "2026-09-12T10:00:00+03:00",
36
+ "author": "Danny",
37
+ "message": "docs(work): created thing\n\ncreated epic=an-epic\n\nWork-Item: thing\nWork-Event: created\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
38
+ },
39
+ {
40
+ "sha": "b2",
41
+ "when": "2026-09-12T11:00:00+03:00",
42
+ "author": "Danny",
43
+ "message": "docs(work): moved thing\n\nmoved from=in-progress · to=complete\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
44
+ }
45
+ ],
46
+ "expect": {}
47
+ },
48
+ {
49
+ "name": "recording a run on an item already in progress is a take, and it follows the first",
50
+ "commits": [
51
+ {
52
+ "sha": "c1",
53
+ "when": "2026-09-12T10:00:00+03:00",
54
+ "author": "Danny",
55
+ "message": "docs(work): moved thing\n\nmoved from=queue · to=in-progress\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
56
+ },
57
+ {
58
+ "sha": "c2",
59
+ "when": "2026-09-12T12:00:00+03:00",
60
+ "author": "Danny",
61
+ "message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-b\nWork-Session: 22222222-2222-4222-8222-222222222222\n"
62
+ }
63
+ ],
64
+ "expect": {
65
+ "thing": [
66
+ { "session": "11111111-1111-4111-8111-111111111111", "machine": "box-a", "tookOverFrom": null },
67
+ {
68
+ "session": "22222222-2222-4222-8222-222222222222",
69
+ "machine": "box-b",
70
+ "tookOverFrom": "11111111-1111-4111-8111-111111111111"
71
+ }
72
+ ]
73
+ }
74
+ },
75
+ {
76
+ "name": "renewing your own take is not a second holder, and keeps the place you first took",
77
+ "commits": [
78
+ {
79
+ "sha": "d1",
80
+ "when": "2026-09-12T10:00:00+03:00",
81
+ "author": "Danny",
82
+ "message": "docs(work): moved thing\n\nmoved from=queue · to=in-progress\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
83
+ },
84
+ {
85
+ "sha": "d2",
86
+ "when": "2026-09-12T11:00:00+03:00",
87
+ "author": "Danny",
88
+ "message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-b\nWork-Session: 22222222-2222-4222-8222-222222222222\n"
89
+ },
90
+ {
91
+ "sha": "d3",
92
+ "when": "2026-09-12T12:00:00+03:00",
93
+ "author": "Danny",
94
+ "message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
95
+ }
96
+ ],
97
+ "expect": {
98
+ "thing": [
99
+ { "session": "11111111-1111-4111-8111-111111111111", "machine": "box-a", "tookOverFrom": null },
100
+ {
101
+ "session": "22222222-2222-4222-8222-222222222222",
102
+ "machine": "box-b",
103
+ "tookOverFrom": "11111111-1111-4111-8111-111111111111"
104
+ }
105
+ ]
106
+ }
107
+ },
108
+ {
109
+ "name": "a take that names no run is still a take, by whoever git says made it",
110
+ "commits": [
111
+ {
112
+ "sha": "e1",
113
+ "when": "2026-09-12T10:00:00+03:00",
114
+ "author": "A Person",
115
+ "message": "docs(work): moved thing\n\nmoved from=queue · to=in-progress\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\n"
116
+ },
117
+ {
118
+ "sha": "e2",
119
+ "when": "2026-09-12T11:00:00+03:00",
120
+ "author": "Danny",
121
+ "message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-b\nWork-Session: 22222222-2222-4222-8222-222222222222\n"
122
+ }
123
+ ],
124
+ "expect": {
125
+ "thing": [
126
+ { "session": "", "machine": "box-a", "tookOverFrom": null },
127
+ {
128
+ "session": "22222222-2222-4222-8222-222222222222",
129
+ "machine": "box-b",
130
+ "tookOverFrom": "A Person"
131
+ }
132
+ ]
133
+ }
134
+ },
135
+ {
136
+ "name": "a commit carrying neither a run nor an event is somebody's hand edit, not a take",
137
+ "commits": [
138
+ {
139
+ "sha": "f1",
140
+ "when": "2026-09-12T10:00:00+03:00",
141
+ "author": "Danny",
142
+ "message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-a\n"
143
+ }
144
+ ],
145
+ "expect": {}
146
+ },
147
+ {
148
+ "name": "two items in one history keep separate holders",
149
+ "commits": [
150
+ {
151
+ "sha": "g1",
152
+ "when": "2026-09-12T10:00:00+03:00",
153
+ "author": "Danny",
154
+ "message": "docs(work): moved one\n\nmoved from=queue · to=in-progress\n\nWork-Item: one\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
155
+ },
156
+ {
157
+ "sha": "g2",
158
+ "when": "2026-09-12T11:00:00+03:00",
159
+ "author": "Danny",
160
+ "message": "docs(work): moved two\n\nmoved from=queue · to=in-progress\n\nWork-Item: two\nWork-Event: moved\nWork-Machine: box-b\nWork-Session: 22222222-2222-4222-8222-222222222222\n"
161
+ }
162
+ ],
163
+ "expect": {
164
+ "one": [
165
+ { "session": "11111111-1111-4111-8111-111111111111", "machine": "box-a", "tookOverFrom": null }
166
+ ],
167
+ "two": [
168
+ { "session": "22222222-2222-4222-8222-222222222222", "machine": "box-b", "tookOverFrom": null }
169
+ ]
170
+ }
171
+ }
172
+ ]
173
+ }
@@ -0,0 +1,72 @@
1
+ """Who is on an item, and who was — derived from the commits that took it.
2
+
3
+ The same derivation the board's TypeScript runs (`packages/board/src/holders.ts`),
4
+ because the terminal and the board are two doors onto one answer. They disagreed on
5
+ exactly the case this settles: a take on an item already in progress changes no
6
+ bucket, so a reader that counted only moves hid the second taker the board was naming.
7
+ Both run `holders.cases.json`, so a drift between the two languages is a red test in
8
+ both suites rather than two names for one holder.
9
+
10
+ Nothing is stored and nothing expires. A take is a moment, so how long ago is derived
11
+ by whoever asks, and whether that session is still there is `peers`'s question.
12
+
13
+ Pure: it reads rows and returns dicts, so both event backends feed it the same way.
14
+ """
15
+
16
+
17
+ def to_holders(rows: list) -> list:
18
+ """Everyone who has taken ONE item, in the order they first took it.
19
+
20
+ `rows` is that item's history, oldest first, as `events.read(..., eventless=True)`
21
+ returns it. Two shapes count as taking it, and nothing else does: a move INTO
22
+ in-progress, and a commit for this item carrying a run but no event — what
23
+ recording a session writes, and the only record a take on an item already in
24
+ progress leaves. `created`, `verified` and the rest are this item's history
25
+ without being a claim on it.
26
+
27
+ One entry per session, carrying its latest take, so renewing your own work is not
28
+ a second holder standing beside you. A take that followed somebody else's names
29
+ them, so an override is visible without anybody having flagged it.
30
+ """
31
+ takes, commits = [], set()
32
+ for row in rows:
33
+ sha = row.get("sha")
34
+ if sha and sha in commits:
35
+ continue
36
+ moved = row.get("event") == "moved" and row.get("to") == "in-progress"
37
+ recorded = not row.get("event") and bool(row.get("by"))
38
+ if not (moved or recorded):
39
+ continue
40
+ if sha:
41
+ commits.add(sha)
42
+ takes.append({"session": str(row.get("by", "")),
43
+ "machine": str(row.get("machine", "")),
44
+ "by": str(row.get("author", "")),
45
+ "taken": str(row.get("ts", ""))})
46
+
47
+ # Keyed by session, falling back to who git says made it, and a later take
48
+ # replaces an earlier one IN PLACE — a dict keeps where a key was first set,
49
+ # which is what the TypeScript side's Map does, so both list holders in one order.
50
+ distinct = {}
51
+ for take in takes:
52
+ distinct[take["session"] or take["by"]] = take
53
+ holders = list(distinct.values())
54
+
55
+ for previous, current in zip(holders, holders[1:]):
56
+ current["took_over_from"] = previous["session"] or previous["by"]
57
+ return holders
58
+
59
+
60
+ def to_holders_by_item(rows: list) -> dict:
61
+ """Everyone on every item a history names, in one pass — `{item: [holder, ...]}`.
62
+
63
+ The board is read as a page of items, so this is the question both readers ask.
64
+ Rows arrive oldest first and keep that order within each item, because an
65
+ override is derived from the order of the takes.
66
+ """
67
+ by_item = {}
68
+ for row in rows:
69
+ name = row.get("name")
70
+ if name:
71
+ by_item.setdefault(name, []).append(row)
72
+ return {name: to_holders(history) for name, history in by_item.items()}
@@ -12,6 +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 . import tree as _tree
15
16
  from .model import locate_feature, scan, scan_features, scan_filed
16
17
  from .registry import code_vocabulary, locate_domain
17
18
 
@@ -193,6 +194,41 @@ def _shape_lint(root: Path, s: dict) -> list:
193
194
  f"fits — or, if this goal genuinely needs only these, say why in "
194
195
  f"`one_goal:`")
195
196
 
197
+ # 3c — a FAT epic: more open tasks than an epic's ceiling. The other end of the
198
+ # floor above and the same concept — an epic is a size a person can plan
199
+ # together. OPEN tasks only: an epic whose work has mostly shipped is not hard
200
+ # to plan, and counting finished work would warn on every long-lived goal
201
+ # about tasks nobody is still doing. Same skips as the floor, same escape.
202
+ #
203
+ # Splitting an epic changes no cut's total, so this bound can never make the
204
+ # cut ceiling worse. It buys a readable plan, not a release that closes.
205
+ for e in [e for v in s["versions"] for e in v.epics]:
206
+ if not e.planned or e.done_tier:
207
+ continue
208
+ n = sum(t.status in ACTIVE for t in e.all_tasks())
209
+ if n > _tree.EPIC_TASK_CEILING and not e.one_goal:
210
+ warns.append(
211
+ f"epic {e.name}: {n} open task(s) — past {_tree.EPIC_TASK_CEILING} an "
212
+ f"epic is no longer a goal you plan together. Split it along its "
213
+ f"goals, or, if this size is deliberate, say why in `one_goal:`")
214
+
215
+ # 3d — a FULL cut: more open tasks, summed across its epics, than a cut's
216
+ # ceiling. Flagged — never refused and never routed. Where a task belongs is
217
+ # a planning call, and a harness that moved work out of a cut on its own
218
+ # would be deciding which half of a goal ships. So this names what is owed
219
+ # and the two ways out, and says whose call it is. Printed after every board
220
+ # write, so whoever files into a full cut reads it on the spot. Epic COUNT is
221
+ # deliberately not bounded; only the sum is.
222
+ for v in s["versions"]:
223
+ over = v.over_ceiling()
224
+ if over:
225
+ warns.append(
226
+ f"cut {v.name}: {len(v.open_tasks())} open tasks, {over} past its "
227
+ f"ceiling of {_tree.CUT_TASK_CEILING} — a reshape is owed so it does "
228
+ f"not close half-built: move work to a later cut, or reshape an epic. "
229
+ f"That is a person's call; an agent parks it. If the size is "
230
+ f"deliberate, say why in `one_goal:` in its version.md")
231
+
196
232
  # 3b — a task inside an epic carrying its own plan.md. The design is settled
197
233
  # ONCE in `epic.md` §Plan; a second design doc per task is the
198
234
  # re-analyse-per-task cost the tier exists to remove. Warn, don't block:
@@ -46,11 +46,26 @@ def _uncommitted(repo: Path, work: Path) -> list:
46
46
  if len(line) <= 3:
47
47
  continue
48
48
  p = (repo / line[3:].strip().strip('"')).resolve()
49
+ # A leftover lease file is untracked by design and is exactly what this
50
+ # migration removes, so it cannot also be the reason it refuses to run.
51
+ if p.name == STALE_CLAIM:
52
+ continue
49
53
  if work.resolve() == p or work.resolve() in p.parents:
50
54
  dirty.append(str(p.relative_to(repo)))
51
55
  return sorted(dirty)
52
56
 
53
57
 
58
+ #: The file the deleted lease left beside a task. Nothing reads or writes one any
59
+ #: more and git no longer filters it out of a commit, so a leftover would be committed
60
+ #: by the next board write in its folder unless a migration removes it first.
61
+ STALE_CLAIM = ".claim"
62
+
63
+
64
+ def _stale_claims(root: Path) -> list:
65
+ """Every leftover lease file under the work tree."""
66
+ return sorted(root.rglob(STALE_CLAIM))
67
+
68
+
54
69
  def _archived_cuts(root: Path) -> list:
55
70
  """Cuts sitting in the archive's former home, `work/archive/versions/<cut>/`.
56
71
 
@@ -81,6 +96,7 @@ def cmd_migrate(args) -> int:
81
96
  archive = archive_dir(root)
82
97
  complete = complete_dir(root)
83
98
  cuts = _archived_cuts(root)
99
+ stale = _stale_claims(root)
84
100
 
85
101
  # The hard refusals. Two of anything means somebody has already started this,
86
102
  # or a repo has grown a second copy — and merging two trees is a judgement
@@ -104,6 +120,8 @@ def cmd_migrate(args) -> int:
104
120
  todo.append("versions/archive/ is created")
105
121
  if not complete.is_dir():
106
122
  todo.append("versions/complete/ is created")
123
+ if stale:
124
+ todo.append(f"{len(stale)} leftover lease file(s) are removed")
107
125
  if not todo:
108
126
  print(f"{rel(root, root)} is already on the current layout — nothing to do")
109
127
  return 0
@@ -116,6 +134,8 @@ def cmd_migrate(args) -> int:
116
134
  print(f" {sum(1 for _ in old.rglob('task.md'))} task(s) would move")
117
135
  for c in cuts:
118
136
  print(f" archived: {c.name}")
137
+ for p in stale:
138
+ print(f" leftover lease file: {rel(p, root)}")
119
139
  return 0
120
140
 
121
141
  dirty = _uncommitted(repo, root)
@@ -182,6 +202,17 @@ def cmd_migrate(args) -> int:
182
202
  elif stale_home.is_dir() and not any(stale_home.iterdir()):
183
203
  stale_home.rmdir()
184
204
 
205
+ # Found again rather than reused: moving the backlog above carries any leftover
206
+ # inside it to a new path, and that branch reuses the name `stale` for its own list.
207
+ leftovers = _stale_claims(root)
208
+ if leftovers:
209
+ # Removed rather than moved: a lease said who held an item for a few hours,
210
+ # the commit history answers that for good now, and nothing reads one.
211
+ for p in leftovers:
212
+ p.unlink()
213
+ print(f"removed {len(leftovers)} leftover lease file(s) — who is on an item is "
214
+ f"read from the commit history now")
215
+
185
216
  print("done — commit this, then every machine working this repo needs a CLI "
186
217
  "new enough to read it")
187
218
  return 0