@appchy/jarvis 0.1.129 → 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.
@@ -7,28 +7,28 @@ any of the harness's guard rails are loaded. `next` is that entry point, and eve
7
7
  input it uses already existed: priority, `depends_on`, the bucket, the version's
8
8
  derived status, and the task's `tier:` against `autonomy.ceiling`.
9
9
 
10
- **Coordination is a file, not a service.** A `.claim` beside `task.md` holds the
11
- instance id and an expiry. A second `next` never returns a claimed task; a claim
12
- whose `code:` regions are already held live by another instance is refused and the
13
- selector moves on, which reuses the region overlap `jarvis work code` has computed all
14
- along. Expired claims are reaped on read, so an instance that crashes at 3am frees
15
- its work without anyone being paged.
10
+ **Who is on something is read from the board's own history, and from nothing else.**
11
+ Taking an item is the commit that moved it into progress, or that recorded a run on
12
+ one already there, so a second `next` sees every session's work a person's at a
13
+ terminal included, which a lease written only by this loop never could. A task whose
14
+ `code:` regions overlap an in-progress item somebody may still be working is skipped,
15
+ and the selector moves on. Nothing expires and nothing is reaped: an area is free
16
+ once everyone holding it has ended.
16
17
 
17
18
  `status` is the same data read from the other end — what shipped, what is waiting
18
19
  on you, what is at risk. Derived, never declared.
19
20
  """
20
- import json
21
+ import os
21
22
  import shutil
22
23
  import sys
23
- from datetime import date, datetime, timedelta, timezone
24
- from pathlib import Path
24
+ from datetime import date, datetime, timezone
25
25
 
26
26
  from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
27
27
  from .frontmatter import rewrite_file
28
- from .model import locate, missing, record_session, scan
28
+ from .model import locate, record_session, scan
29
29
  from .generate import _sync
30
30
  from .epic import plans_held
31
- from . import autonomy, events, links, peers
31
+ from . import autonomy, events, holders, links, peers
32
32
  # The ceiling is read through the MODULE, never bound in with `from … import`.
33
33
  # A `from .autonomy import CEILING` captures the value at import time, so
34
34
  # `config.apply` setting it afterwards would leave this file quietly running on
@@ -36,64 +36,39 @@ from . import autonomy, events, links, peers
36
36
  # harness is built to avoid.
37
37
  from .autonomy import _open_questions, tier_of
38
38
 
39
- CLAIM = ".claim"
40
- #: How long a claim is good for. Long enough that a real task finishes inside it,
41
- #: short enough that a crashed instance's work is takeable the same day. Config
42
- #: moves it (`autonomy.lease_minutes`).
43
- LEASE_MINUTES = 240
44
39
 
40
+ def _takes(root) -> dict:
41
+ """Everyone on every item, out of the board's own history — one read for the
42
+ whole tree, shared by every question this module asks of it."""
43
+ return holders.to_holders_by_item(events.read(root, eventless=True))
45
44
 
46
- def _now():
47
- return datetime.now(timezone.utc).replace(microsecond=0)
48
45
 
46
+ def _still_there(holder: dict) -> bool:
47
+ """Whether a holder may still be working, for the one decision this gates.
49
48
 
50
- def read_claim(folder: Path):
51
- """The live claim on a task folder, or None. An expired or unreadable claim is
52
- treated as absent a lease nobody can parse must not be able to strand work
53
- forever, which is the failure mode of every lock that fails closed."""
54
- p = folder / CLAIM
55
- if not p.is_file():
56
- return None
57
- try:
58
- data = json.loads(p.read_text())
59
- expires = datetime.fromisoformat(str(data["expires"]))
60
- # TypeError is in the list because the comparison below throws it, not the
61
- # parse: a claim written without a timezone reads fine and then cannot be
62
- # compared against an aware `now`. That raised out of a function whose whole
63
- # contract is that an unreadable claim reads as absent so a single
64
- # malformed `.claim` took down every command that asks who holds an item,
65
- # which is the fail-closed lock this was written to avoid being.
66
- if expires <= _now():
67
- return None
68
- except (OSError, TypeError, ValueError, KeyError, json.JSONDecodeError):
69
- return None
70
- return data
71
-
72
-
73
- def _write_claim(folder: Path, instance: str) -> dict:
74
- data = {
75
- "instance": instance,
76
- # WHICH BOX, recorded because an id alone cannot say whether the session
77
- # holding this is one you can talk to or one on another machine entirely.
78
- # A claim never enters git, so this is the only place the pairing exists.
79
- "machine": peers.here(),
80
- "taken": _now().isoformat(),
81
- "expires": (_now() + timedelta(minutes=LEASE_MINUTES)).isoformat(),
82
- }
83
- (folder / CLAIM).write_text(json.dumps(data, indent=2) + "\n")
84
- return data
85
-
86
-
87
- def _live_claims(s: dict) -> list:
88
- """Every unexpired claim across the tree, as (claim, task) — the input to both
89
- the same-task check and the region-conflict check."""
90
- out = []
91
- for v in s["versions"]:
92
- for t in v.all_tasks():
93
- c = read_claim(t.folder)
94
- if c:
95
- out.append((c, t))
96
- return out
49
+ Only `ended` says no. A session this machine cannot see — on another machine, on
50
+ a client that publishes nothing, or a shift that names itself counts as still
51
+ there, because what this answer decides is the unattended loop HOLDING BACK: a
52
+ wrong yes costs a task not taken tonight, and a wrong no costs two runs on one
53
+ area. It never refuses a person and never lets anything go ahead that would not
54
+ have gone ahead anyway."""
55
+ return peers.reach(holder["session"], holder["machine"])["live"] != "ended"
56
+
57
+
58
+ def somebody_else(root, name: str) -> str:
59
+ """A note for whoever is about to move `name` when somebody other than them may
60
+ still be on it, or "" when nobody is.
61
+
62
+ It warns and never refuses: `next` is the door that declines held work, and a
63
+ person who has decided to take something over is owed who to talk to before the
64
+ two of them collide, not an argument about it."""
65
+ mine = peers.me()
66
+ others = [h for h in holders.to_holders(events.read(root, name=name, eventless=True))
67
+ if h["session"] != mine and _still_there(h)]
68
+ if not others:
69
+ return ""
70
+ said = "; ".join(peers.describe(h["session"], h["machine"]) for h in others)
71
+ return f"note: '{name}' may still be held by another session — {said}"
97
72
 
98
73
 
99
74
  def _blockers(task, s: dict) -> str:
@@ -112,50 +87,59 @@ def _blockers(task, s: dict) -> str:
112
87
  return ""
113
88
 
114
89
 
115
- def _region_conflict(task, claims: list) -> str:
90
+ def _region_conflict(task, held: list) -> str:
91
+ """Why `task` overlaps work somebody may still be doing, or "".
92
+
93
+ `held` is every (holder, in-progress item) pair whose holder is not this instance
94
+ and has not ended. Overlap is exact and says nothing finer: where a repo's work
95
+ sits in one region, everything waits while anyone is working, which is the safe
96
+ direction for an unattended run to be wrong in."""
116
97
  if not task.code:
117
98
  return ""
118
- for c, other in claims:
99
+ for holder, other in held:
119
100
  if other.name == task.name:
120
101
  continue
121
102
  overlap = sorted(set(task.code) & set(other.code))
122
103
  if overlap:
123
- # Naming the holder is only half an answer — the half that leaves you
124
- # waiting for a lease. The other half is whether that session is one
125
- # you can actually talk to about it.
126
- return (f"code region(s) {', '.join(overlap)} held by "
127
- f"'{other.name}' · {peers.of_claim(c)}")
104
+ return (f"code region(s) {', '.join(overlap)} held by '{other.name}' · "
105
+ f"{peers.describe(holder['session'], holder['machine'])}")
128
106
  return ""
129
107
 
130
108
 
131
109
  def cmd_next(args) -> int:
132
- """Hand this instance exactly one task, and claim it.
110
+ """Hand this instance exactly one task.
133
111
 
134
- `--peek` selects without claiming or moving — for a human asking what the shift
112
+ `--peek` selects without taking or moving — for a human asking what the shift
135
113
  would take next, which must not have the side effect of taking it."""
136
114
  root = find_work_root()
137
- instance = (args.get("instance") or events.instance_id() or "local").strip()
115
+ instance = (args.get("instance") or events.instance_id() or "").strip()
116
+ if args.get("instance"):
117
+ # A take is recorded under whoever this run is known as, so a name given here
118
+ # has to be that name — or this run would read its own take as somebody
119
+ # else's and hold back from its own work.
120
+ os.environ["WORK_INSTANCE"] = instance
138
121
  peek = bool(args.get("peek"))
139
122
  s = scan(root)
140
- claims = _live_claims(s)
123
+ live = [v for v in s["versions"] if not v.released]
124
+ in_flight = [t for v in live for t in v.bucket("in-progress")]
125
+ takes = _takes(root)
141
126
 
142
127
  # An instance that already holds work resumes it rather than taking more. One
143
128
  # instance, one task — a shift juggling three tasks is three half-finished
144
129
  # tasks, and unattended nobody notices until morning.
145
- for c, t in claims:
146
- if c["instance"] == instance:
147
- print(f"RESUME {t.name} — {t.title} [claimed {c['taken']}]")
130
+ for t in in_flight:
131
+ mine = [h for h in takes.get(t.name, []) if instance and h["session"] == instance]
132
+ if mine:
133
+ print(f"RESUME {t.name} — {t.title} [taken {mine[0]['taken']}]")
148
134
  _read_order(t, root)
149
135
  return 0
150
136
 
151
- live = [v for v in s["versions"] if not v.released]
137
+ held = [(h, t) for t in in_flight for h in takes.get(t.name, [])
138
+ if h["session"] != instance and _still_there(h)]
152
139
  candidates, skipped = [], []
153
140
  for v in sorted(live, key=lambda x: (x.order, x.name)):
154
141
  for t in v.bucket("queue"):
155
- if read_claim(t.folder):
156
- skipped.append((t, "claimed by another instance"))
157
- continue
158
- why = _blockers(t, s) or _region_conflict(t, claims)
142
+ why = _blockers(t, s) or _region_conflict(t, held)
159
143
  if why:
160
144
  skipped.append((t, why))
161
145
  continue
@@ -193,13 +177,13 @@ def cmd_next(args) -> int:
193
177
  md,
194
178
  lambda d: d.update({"updated": date.today().isoformat()}),
195
179
  )
196
- claim = _write_claim(dest, instance)
197
180
  record_session(dest)
181
+ # The move IS the take: its commit names the run and the machine, and that record
182
+ # is the one every reader derives who is on this from.
198
183
  events.append(root, "moved", task.name, **{"from": "queue", "to": "in-progress"})
199
- events.append(root, "claimed", task.name, expires=claim["expires"])
200
184
 
201
185
  print(f"TAKE {task.name} — {task.title} "
202
- f"[{task.priority} · tier {tier_of(task)} · lease to {claim['expires']}]")
186
+ f"[{task.priority} · tier {tier_of(task)}]")
203
187
  task = locate(root, task.name)
204
188
  _read_order(task, root)
205
189
  _sync(root)
@@ -227,23 +211,6 @@ def _read_order(task, root) -> None:
227
211
  "ABOVE CEILING: do not decide alone."))
228
212
 
229
213
 
230
- def cmd_drop(args) -> int:
231
- """Give a task back — the honest end to a shift that cannot finish it."""
232
- root = find_work_root()
233
- task = locate(root, args["name"])
234
- if not task:
235
- die(missing(root, args["name"]))
236
- p = task.folder / CLAIM
237
- if p.is_file():
238
- p.unlink()
239
- events.append(root, "released-claim", task.name,
240
- why=(args.get("why") or "").strip() or None)
241
- print(f"released claim on '{task.name}'")
242
- else:
243
- print(f"'{task.name}' has no claim")
244
- return 0
245
-
246
-
247
214
  def _ago(ts: str) -> str:
248
215
  """How long ago a recorded moment was, in the coarsest unit that still says
249
216
  something. "6h ago" answers *is anyone on this* far better than a timestamp the
@@ -303,7 +270,7 @@ def cmd_status(args) -> int:
303
270
  # One read of the record, shared by every section below. It is a `git log`
304
271
  # under the git backend, so asking three times is three subprocesses for one
305
272
  # answer that cannot change between them.
306
- log = events.read(root)
273
+ log = events.read(root, eventless=True)
307
274
  recent = [e for e in log if e["event"] == "completed"][-5:]
308
275
  print(f"\nSHIPPED (last {len(recent)})")
309
276
  for e in recent:
@@ -312,39 +279,36 @@ def cmd_status(args) -> int:
312
279
  if not recent:
313
280
  print(" nothing recorded yet.")
314
281
 
315
- # Who last touched each item, out of the commits. A lease answers this only for
316
- # the unattended loop an interactive session writes none at all, so reading
317
- # claims alone printed `unclaimed` against every item a person was working and
318
- # told the one screen the shift is read from nothing whatsoever.
319
- touched = {}
320
- for e in log:
321
- if e.get("by"):
322
- touched[e["name"]] = e
282
+ # Who is on each item, out of the commits that took it the derivation the board
283
+ # and `next` read too, so the three cannot name different holders.
284
+ takes = holders.to_holders_by_item(log)
323
285
 
324
286
  in_flight = [t for v in live for t in v.bucket("in-progress")]
325
287
  print(f"\nIN FLIGHT ({len(in_flight)})")
326
288
  for t in in_flight:
327
- c = read_claim(t.folder)
328
- last = touched.get(t.name)
329
- if c:
330
- who = f" · {c['instance'][:8]} until {c['expires'][11:16]}Z"
331
- elif last:
332
- who = f" · last touched {_ago(last['ts'])}"
333
- else:
334
- who = " · never touched"
289
+ found = takes.get(t.name, [])
290
+ latest = max(found, key=lambda h: h["taken"]) if found else None
291
+ who = f" · last taken {_ago(latest['taken'])}" if latest else " · never taken"
335
292
  print(f" {t.name} — {t.title} [tier {tier_of(t)}{who}]")
336
293
  # Only for work that is somebody ELSE's: on your own tasks this would be a
337
294
  # line about yourself on every status, which is how a useful line becomes
338
- # one people stop reading.
339
- if c and c.get("instance") != peers.me():
340
- print(f" {peers.of_claim(c)}")
341
- elif not c and last and last["by"] != peers.me():
342
- print(f" {peers.describe(last['by'], last.get('machine', ''))}")
295
+ # one people stop reading. Takes whose run has ended collapse to a count, and
296
+ # the newest of them is named only when nobody at all is left on the item.
297
+ others = [h for h in found if h["session"] != peers.me()]
298
+ still = [h for h in others if _still_there(h)]
299
+ for h in still:
300
+ print(f" {peers.describe(h['session'], h['machine'])}")
301
+ ended = [h for h in others if h not in still]
302
+ if ended and not still:
303
+ newest = max(ended, key=lambda h: h["taken"])
304
+ print(f" {peers.describe(newest['session'], newest['machine'])}")
305
+ ended.remove(newest)
306
+ if ended:
307
+ print(f" + {len(ended)} earlier take(s) whose run has ended")
343
308
  if not in_flight:
344
309
  print(" nothing is being worked on.")
345
310
 
346
- # At risk: claimed-but-expired (an instance died), and gates that keep refusing.
347
- stale = [t for t in in_flight if not read_claim(t.folder) and (t.folder / CLAIM).is_file()]
311
+ # At risk: gates that keep refusing.
348
312
  # A task refused repeatedly is a task not converging — UNLESS it is parked with
349
313
  # an open question, which is the designed outcome rather than a failure. Counting
350
314
  # those would put the harness's own correct behaviour on the risk list, and a
@@ -355,12 +319,10 @@ def cmd_status(args) -> int:
355
319
  if e["event"] == "gate-refused" and e["name"] not in waiting:
356
320
  refused[e["name"]] = refused.get(e["name"], 0) + 1
357
321
  repeat = {n: c for n, c in refused.items() if c >= 2}
358
- print(f"\nAT RISK ({len(stale) + len(repeat)})")
359
- for t in stale:
360
- print(f" {t.name}: lease expired — the instance working it is gone")
322
+ print(f"\nAT RISK ({len(repeat)})")
361
323
  for n, c in sorted(repeat.items()):
362
324
  print(f" {n}: completion refused {c}× — it is not converging")
363
- if not stale and not repeat:
325
+ if not repeat:
364
326
  print(" nothing.")
365
327
 
366
328
  print("\nBOARD")
@@ -3,7 +3,7 @@ import shutil
3
3
  import sys
4
4
  from datetime import date
5
5
 
6
- from . import peers, tree
6
+ from . import tree
7
7
  # `TASK_TAGS_OK` is deliberately NOT imported by name: `config.apply` binds it on
8
8
  # the `tree` module, and a `from … import` captures the value at import time, so
9
9
  # the name here would still hold the pre-config default. Read through the module.
@@ -334,11 +334,10 @@ def cmd_move(args) -> int:
334
334
  # what a person uses when they have decided to take something over — and a
335
335
  # harness that argued with that would just get worked around. What it owes is
336
336
  # the fact, and who to talk to before the two of you collide.
337
- from .shift import read_claim
338
- held = read_claim(task.folder)
339
- if held and held.get("instance") != peers.me():
340
- print(f"note: '{name}' is held by another session — {peers.of_claim(held)}",
341
- file=sys.stderr)
337
+ from .shift import somebody_else
338
+ note = somebody_else(root, name)
339
+ if note:
340
+ print(note, file=sys.stderr)
342
341
 
343
342
  # The gate runs BEFORE the folder moves. It used to warn after — which meant
344
343
  # the task was already sitting in `complete/` when the warning printed, and
@@ -405,11 +404,6 @@ def cmd_move(args) -> int:
405
404
  # (a blank scaffold on every pickup was noise). Working checklists live in
406
405
  # native TodoWrite, so completion strips nothing.
407
406
  if to == "complete":
408
- # The lease is the claim on WORK IN FLIGHT; finished work holds nothing.
409
- # Leaving it would make `status` report a live instance on a done task.
410
- claim = dest / ".claim"
411
- if claim.is_file():
412
- claim.unlink()
413
407
  events.append(root, "completed", name, delivered=delivered or None,
414
408
  not_included=not_included or None)
415
409
  # The forwardable line. `not_included` is asked for every time and never
@@ -133,6 +133,23 @@ TASK_REGION_CAP = 3
133
133
  # has not cut yet), which is why this warns rather than blocks: an epic that is
134
134
  # planned deep and cut shallow is legitimate, and says so in its own §Plan.
135
135
  EPIC_TASK_FLOOR = 3
136
+ # The other end of the epic bound, and the first bound on a CUT. Both count OPEN
137
+ # work — queue, in-progress, blocked — because what is hard to plan together, or to
138
+ # close, is what is still owed rather than what already shipped inside it.
139
+ #
140
+ # Soft in exactly the shape the floor and the region cap already are. An epic past
141
+ # its ceiling warns, and a written `one_goal:` answers it. A cut past its ceiling is
142
+ # FLAGGED as owing a reshape — never refused, never routed. Founder's call,
143
+ # 2026-09-12, made against the live counts (epics at 17, 10, 8, 7 open; cuts at 24,
144
+ # 15, 13, 9): "it should be resolved and either be in a new cut or the epic, depends
145
+ # on the task … to make sure we dont land a half baked cut". Where a task belongs is
146
+ # a planning call, so the harness says what is owed and a person decides.
147
+ #
148
+ # These are only the shipped defaults: `ceilings` in config moves them, because the
149
+ # harness serves repos of very different sizes. READ THROUGH THE MODULE — a
150
+ # `from .tree import` captures the default before config has bound the repo's own.
151
+ EPIC_TASK_CEILING = 8
152
+ CUT_TASK_CEILING = 15
136
153
  # A feature's lifecycle, product-language not engineering-status.
137
154
  PRODUCT_STATES = ("idea", "defined", "building", "shipped", "retired")
138
155
  BACKLOG_START = "<!-- BACKLOG:START -->"
@@ -348,12 +348,10 @@ def _archive_task(root, name: str, args) -> int:
348
348
  # Somebody may be on it. Both notes WARN rather than refuse, exactly as `move`
349
349
  # and the take-it-out-of-a-cut door already do: a harness that argues about a
350
350
  # call the person has made gets worked around.
351
- from .shift import read_claim
352
- from . import peers
353
- held = read_claim(task.folder)
354
- if held and held.get("instance") != peers.me():
355
- print(f"note: '{name}' is held by another session — {peers.of_claim(held)}",
356
- file=sys.stderr)
351
+ from .shift import somebody_else
352
+ note = somebody_else(root, name)
353
+ if note:
354
+ print(note, file=sys.stderr)
357
355
  if task.status == "in-progress":
358
356
  print(f"note: '{name}' is in progress — somebody may be working it right "
359
357
  f"now, and this takes it out from under them.", file=sys.stderr)
@@ -555,31 +555,30 @@ Work-Session: <the run that made the change>
555
555
  ```
556
556
 
557
557
  Four keys and no more: who, when and which branch are git's own, and a second copy of a fact is a
558
- second thing to drift. Two things never reach git — a **hold** (`.claim`), because coordination that
559
- replicates is two machines holding one item with neither knowing, and a **refused completion**,
560
- because nothing changed for a commit to carry.
558
+ second thing to drift. One thing never reaches git — a **refused completion**, because nothing changed
559
+ for a commit to carry.
561
560
 
562
561
  **One consequence worth holding on to**: most commits in this repo are board writes that change no
563
562
  code, and `stale` is defined as *graph older than HEAD*. See §Plan against the graph.
564
563
 
565
564
  ## Somebody else is on it
566
565
 
567
- The harness knows who else is working — a `.claim` carries an instance id and a machine, a board
568
- commit carries `Work-Session` and `Work-Machine`. Where that matters it says **whether you can reach
569
- them**:
566
+ The harness knows who else is working — every board commit carries `Work-Session` and
567
+ `Work-Machine`, and who is on an item is derived from the commits that took it. Where that matters it
568
+ says **whether you can reach them**:
570
569
 
571
570
  | You see it | When |
572
571
  |---|---|
573
572
  | the session block, at startup | somebody else is live in this checkout — who, on what, and how to reach them. **Silence means you are alone** |
574
573
  | `jarvis work peers` | you asked again, because that line was true when it printed and not for long |
575
- | `next` skips a task | its `code:` regions are held by another session |
576
- | `move <name> in-progress` | a live claim on it belongs to somebody else — a **note**, not a refusal |
574
+ | `next` skips a task | its `code:` regions overlap an item held by a session that has not ended, or that this machine cannot see |
575
+ | `move <name> in-progress` | somebody else took it and may still be on it — a **note**, not a refusal |
577
576
  | `status` → IN FLIGHT | a task is held by a session that is not you |
578
577
  | a board read | every holder carries whether its run is still going and what reaches it |
579
578
  | any write | a pull brought board changes in: *the board moved under you — <what> · from <who>* |
580
579
 
581
580
  - **`— this machine`** → that session is addressable. `ListAgents` lists it, `SendMessage` reaches it.
582
- Use it when the plan has to change and waiting for a lease to expire is the wrong answer.
581
+ Use it when the plan has to change.
583
582
  - **`— another machine`** → it cannot be reached from here. The board is the only thing you share, so
584
583
  say it on the board: `ask`, a `handoff`, or a task note.
585
584
  - **`ENDED`** → that is abandoned work, not a peer to negotiate with. Nobody is there to ask, so the
@@ -615,10 +614,9 @@ this repo that refuses produced a hand-written forty-iteration retry loop — so
615
614
  conversation is your call. A `move` that warns still moves — `next` is the door that declines held
616
615
  work.
617
616
 
618
- **Two things about holds.** A hold belongs to a **person**, not a machine so a session you start can
619
- update and complete an item you are holding, and two of your own sessions never lock each other out.
620
- And a hold is **time-bounded and lives in the daemon's memory**: it does not survive a restart and is
621
- not written into the tree. Take an item when you are about to start it, not to reserve it.
617
+ **There is no hold.** Who is on an item is the commits that took it, read the same way by `status`, the
618
+ board and `next`. Nothing is written beside them, nothing expires, and nothing locks anybody out. Take
619
+ an item when you are about to start it, not to reserve it a take is on the record for good.
622
620
 
623
621
  ## Running unattended — what you decide, and what you park
624
622
 
@@ -659,7 +657,7 @@ this section exists to prevent, and nothing downstream can detect it.
659
657
  **The loop a scheduled instance runs:**
660
658
 
661
659
  ```
662
- jarvis work next # take ONE task, claim it, print the read order
660
+ jarvis work next # take ONE task and print the read order
663
661
  # … read what it printed, build, and then, through TOOLS:
664
662
  work_verify {id} # runs every gate; ask again until it is finished
665
663
  work_complete {id, delivered, notIncluded, observed}
@@ -667,7 +665,7 @@ work_complete {id, delivered, notIncluded, observed}
667
665
  work_update {id, question: {…}} # park it and go back to `next`
668
666
  ```
669
667
 
670
- `next` is the one step with no tool: claiming work unattended is a scheduler's job, not a session's.
668
+ `next` is the one step with no tool: taking work unattended is a scheduler's job, not a session's.
671
669
  Everything after it is a tool call, and a session that cannot finish through them has found a gap
672
670
  worth parking a question about.
673
671
 
@@ -231,7 +231,7 @@ what `next` adds is *choosing*, and what `ask` adds is somewhere to put a questi
231
231
  so the run never stalls and never quietly decides for the founder.
232
232
 
233
233
  ```bash
234
- work.py next # ONE task, claimed, with its read order printed
234
+ work.py next # ONE task, taken, with its read order printed
235
235
  # … build it, then EITHER prove it:
236
236
  work.py verify --task <name>
237
237
  work.py observed <name> --ac AC-01 --saw "what you actually saw"
@@ -196,12 +196,6 @@
196
196
  "maximum": 3,
197
197
  "default": 2,
198
198
  "description": "The highest task `tier:` an unattended run acts on alone. 0 reversible and local · 1 ordinary change behind existing tests · 2 new behaviour or a reversible migration · 3 irreversible, or it touches money, secrets, personal data or the law. Default 2: the shift does ordinary work and parks the rest with `work ask`. 3 means \"decide everything\" and is deliberately never the shipped default."
199
- },
200
- "lease_minutes": {
201
- "type": "integer",
202
- "minimum": 1,
203
- "default": 240,
204
- "description": "How long one instance's claim on a task holds before another may take it. Long enough that real work finishes inside it; short enough that a crashed shift's task is takeable the same day."
205
199
  }
206
200
  }
207
201
  },