@appchy/jarvis 0.1.92 → 0.1.94

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.
@@ -1001,6 +1001,19 @@ def session_pointers(cfg: dict, repo=None) -> list:
1001
1001
  f"always high. Until it fires or the user asks, keep working"
1002
1002
  + (f"; then run `{cfg['wrap']['command']}`." if cfg["wrap"]["command"]
1003
1003
  else "."))
1004
+ # Said whether or not a threshold is configured, because neither half of it
1005
+ # depends on one. The second sentence is the one that changes how a turn ENDS:
1006
+ # the founder's standing complaint was never that the leftovers were hidden, it
1007
+ # was that a session's own closing prose does not say what is actually finished
1008
+ # — so the machine-derived version is on screen already, and re-narrating it in
1009
+ # paragraphs competes with it rather than adding to it.
1010
+ if cfg["hooks"]["stop"]["enabled"]:
1011
+ out.append("the same hook wraps you EARLY when an item you took reaches "
1012
+ "complete — that is the end of the work, not a warning about "
1013
+ "room. At the end of every turn it also shows the person the "
1014
+ "derived standing: the item, its bucket, its criteria and what "
1015
+ "the completion gate would still refuse on. Do not restate any "
1016
+ "of that in prose — say what you decided and what you need.")
1004
1017
  return out
1005
1018
 
1006
1019
 
@@ -1090,8 +1103,13 @@ def _session_state(cfg: dict, repo) -> Path:
1090
1103
  """Where per-machine, per-session markers live — beside the coverage shard, which
1091
1104
  is already the gitignored root for facts that are about this checkout and not
1092
1105
  about the repo. One reader, because the hook that replays these has the default
1093
- baked in and a second answer here would silently stop matching it."""
1094
- return repo / Path(cfg["coverage"]["shard"]).parent
1106
+ baked in and a second answer here would silently stop matching it.
1107
+
1108
+ WHICH directory that is comes from `shard.state_root`, shared with the check that
1109
+ decides whether a file is somebody's uncommitted work — the two have to name the
1110
+ same place, or the harness reports its own markers as a person's."""
1111
+ from .shard import state_root
1112
+ return repo / state_root(cfg["coverage"]["shard"])
1095
1113
  def _remember_systems(cfg: dict, repo, session, systems, off=False) -> None:
1096
1114
  """Leave this repo's file-to-system declarations where the caller can replay them.
1097
1115
 
@@ -1193,41 +1211,47 @@ def cmd_applies(cfg: dict, args=None, repo=None) -> int:
1193
1211
 
1194
1212
 
1195
1213
  def cmd_remind(cfg: dict, args=None, repo=None) -> int:
1196
- """Say whether a session this full should be wrapping up, and what that means here.
1214
+ """Where the work stands at the end of a turn, and whether to wrap this session up.
1215
+
1216
+ **Two answers, and they go to two different readers.** `headline` is one short
1217
+ block for the PERSON: the item, its bucket, its criteria, and what the completion
1218
+ gate would still refuse on. It costs the session nothing and repeats as often as
1219
+ the facts hold, because it replaces a question the founder was typing at the end
1220
+ of nearly every session — the summary a session writes for itself does not say
1221
+ what is actually complete, and this is derived rather than recalled. `note` is for
1222
+ the AGENT and is rationed to once a session: delivering it RESUMES the run so it
1223
+ can act, so a note that arrives every turn is a session that never gets to stop.
1224
+
1225
+ Two things earn the note. Context past the repo's threshold — unchanged, and it
1226
+ lands near the end of a long session — and an item this session took reaching
1227
+ complete, which is the early wrap a session that finishes with room to spare never
1228
+ used to get. They say different first lines on purpose: one is "you are running
1229
+ out of room", the other "the work you took is done".
1197
1230
 
1198
1231
  **It is handed a MEASUREMENT, never a transcript.** How many tokens a session is
1199
1232
  holding is a thing only its client can answer, and every client answers it its own
1200
1233
  way — so measuring is the client's half and it stays there. What is left is the
1201
1234
  part that is the same for all of them: the threshold, the window, the wording, the
1202
1235
  once-per-session rule, and whatever this repo adds. A harness command that took one
1203
- vendor's log file would be a harness that only works for that vendor.
1236
+ vendor's log file would be a harness that only works for that vendor. The
1237
+ measurement is now OPTIONAL: a client that cannot read its own usage still gets
1238
+ everything the tree and git can answer.
1204
1239
 
1205
1240
  Prints one JSON object, or nothing at all. A caller wraps `headline` and `note` in
1206
- whatever its own surface expects; nothing here knows what that looks like.
1241
+ whatever its own surface expects; nothing here knows what that looks like, and
1242
+ `note` may be absent while a headline is not.
1207
1243
 
1208
- Silent on every path where it cannot be sure. A reminder that arrives at the wrong
1209
- moment is worse than one that never arrives — it teaches its reader to ignore the
1210
- next one, and there is only ever one that matters.
1244
+ Silent on every path where it cannot be sure, and silence still means clean. A
1245
+ reminder that arrives at the wrong moment is worse than one that never arrives —
1246
+ it teaches its reader to ignore the next one.
1211
1247
  """
1212
1248
  args = args or {}
1213
- at = cfg["wrap"]["at_percent"]
1214
- window = cfg["wrap"]["context_tokens"]
1215
- try:
1216
- used = int(args.get("used") or 0)
1217
- except (TypeError, ValueError):
1218
- used = 0
1219
- if not at or not window or used <= 0:
1220
- return 0
1221
- percent = used / window * 100
1222
- if percent < at:
1223
- return 0
1224
-
1225
1249
  repo = Path(repo) if repo is not None else Path.cwd()
1226
- if not _first_time(cfg, repo, args.get("session")):
1227
- return 0
1250
+ session = args.get("session")
1228
1251
 
1229
1252
  from . import extend
1230
1253
  from .tree import cli
1254
+ from .wrap import standing
1231
1255
  spec = cfg["hooks"]["stop"]
1232
1256
  extra = extend.run(spec.get("extend"), repo)
1233
1257
  if not spec.get("enabled", True):
@@ -1236,6 +1260,33 @@ def cmd_remind(cfg: dict, args=None, repo=None) -> int:
1236
1260
  print(json.dumps({"headline": "\n".join(extra), "note": "\n".join(extra)}))
1237
1261
  return 0
1238
1262
 
1263
+ at = cfg["wrap"]["at_percent"]
1264
+ window = cfg["wrap"]["context_tokens"]
1265
+ try:
1266
+ used = int(args.get("used") or 0)
1267
+ except (TypeError, ValueError):
1268
+ used = 0
1269
+ percent = (used / window * 100) if (window and used > 0) else 0
1270
+ full = bool(at and percent >= at)
1271
+
1272
+ where = standing(session, repo) if session else None
1273
+ lines = _standing_lines(where) if where else []
1274
+ # Why the note fires, most specific first, and each spends its OWN claim: a
1275
+ # session told once that it is nearly full must still be told when the work it
1276
+ # took is finished, and neither can quietly consume the other's turn to speak.
1277
+ why = []
1278
+ if where and where.finished and _first_time(cfg, repo, session, "wrap-finished"):
1279
+ why.append(f"{_and_list(where.finished)} — the work you took — is complete, so "
1280
+ f"this session has reached its end rather than its limit.")
1281
+ if full and _first_time(cfg, repo, session):
1282
+ why.append(f"Context is {percent:.0f}% full ({used:,} of {window:,} tokens; "
1283
+ f"the threshold is {at}%).")
1284
+ # `full` earns a headline of its own even once the note is spent: the window
1285
+ # keeps filling after the one turn that was allowed to interrupt, and a person
1286
+ # who saw 50% once and nothing at 90% was told less as it got worse.
1287
+ if not lines and not why and not full and not extra:
1288
+ return 0
1289
+
1239
1290
  # Null when the repo names none, and then the reminder says to wrap without naming
1240
1291
  # a command — better than sending the reader to one that resolves for nobody.
1241
1292
  command = cfg["wrap"]["command"]
@@ -1250,28 +1301,93 @@ def cmd_remind(cfg: dict, args=None, repo=None) -> int:
1250
1301
  else:
1251
1302
  last = (f"`{cli()} kickoff <task>` — the prompt that opens the next session on "
1252
1303
  "this work, with the method actually loaded.")
1253
- note = (
1254
- f"Context is {percent:.0f}% full ({used:,} of {window:,} tokens; the threshold "
1255
- f"is {at}%). Finish this session cleanly while there is still room to do it "
1256
- f"well:\n"
1257
- f" 1. `{cli()} handoff <task>` — status, next step, and what you learned.\n"
1258
- f" 2. Bring the task and epic docs to current a stale brief is a trap for "
1259
- f"the next session.\n"
1260
- f" 3. Move anything finished: `{cli()} move <task> complete`.\n"
1261
- f" 4. Park anything still open: `{cli()} ask <task> --question \"…\"`.\n"
1262
- f" 5. {last}\n"
1263
- + (f"`{command}` runs all five. " if command else "")
1264
- + "Say so before you start, and if the user is mid-thought, finish their point "
1265
- "first this is a reminder, not a stop."
1266
- + ("\n" + "\n".join(extra) if extra else "")
1267
- )
1268
- headline = (f"work: {percent:.0f}% of context used ({used // 1000}k/"
1269
- f"{window // 1000}k) — time to wrap up"
1270
- + (f" (`{command}`)." if command else "."))
1271
- print(json.dumps({"headline": headline, "note": note}))
1304
+ note = ""
1305
+ if why:
1306
+ note = (
1307
+ " ".join(why) + " Finish this session cleanly while there is still room "
1308
+ "to do it well:\n"
1309
+ f" 1. `{cli()} handoff <task>`status, next step, and what you learned.\n"
1310
+ f" 2. Bring the task and epic docs to current — a stale brief is a trap "
1311
+ f"for the next session.\n"
1312
+ f" 3. Move anything finished: `{cli()} move <task> complete`.\n"
1313
+ f" 4. Park anything still open: `{cli()} ask <task> --question \"…\"`.\n"
1314
+ f" 5. {last}\n"
1315
+ + (f"`{command}` runs all five. " if command else "")
1316
+ + "Say so before you start, and if the user is mid-thought, finish their "
1317
+ "point first this is a reminder, not a stop."
1318
+ + ("\n" + "\n".join(extra) if extra else "")
1319
+ )
1320
+ elif extra:
1321
+ note = "\n".join(extra)
1322
+
1323
+ head = list(lines)
1324
+ if full:
1325
+ # Said whether or not the note fired. The note is spent after one turn and the
1326
+ # window keeps filling, so a person who saw "50%" once and nothing at 80% has
1327
+ # been told less as it got worse.
1328
+ head.append(f"{percent:.0f}% of context used ({used // 1000}k/"
1329
+ f"{window // 1000}k) — time to wrap up"
1330
+ + (f" (`{command}`)" if command else ""))
1331
+ elif why:
1332
+ head.append("the work you took is complete — time to wrap up"
1333
+ + (f" (`{command}`)" if command else ""))
1334
+ # An extension with nothing of ours to say speaks for itself, exactly as it does
1335
+ # on the path where the shipped half is switched off.
1336
+ headline = ("work: " + "\n ".join(head)) if head else "\n".join(extra)
1337
+ out = {"headline": headline}
1338
+ if note:
1339
+ out["note"] = note
1340
+ print(json.dumps(out))
1272
1341
  return 0
1273
1342
 
1274
1343
 
1344
+ def _and_list(names) -> str:
1345
+ """`a`, `a and b`, `a, b and c` — for a sentence a person reads."""
1346
+ names = list(names)
1347
+ if len(names) < 3:
1348
+ return " and ".join(names)
1349
+ return ", ".join(names[:-1]) + f" and {names[-1]}"
1350
+
1351
+
1352
+ def _standing_lines(where) -> list:
1353
+ """The bottom line, in as few words as it can be said in.
1354
+
1355
+ **What a person cannot get from a session's own summary**, which is where this
1356
+ came from: whether the work is actually complete, and where the item, its epic
1357
+ and the cut stand. So every line is derived — the bucket, the criteria ratio, the
1358
+ completion gate's own verdict — and none of it is this session's account of
1359
+ itself.
1360
+
1361
+ The criteria ratio carries the "not done" case on its own, so the gate's reason
1362
+ is only shown once they are all ticked and something else is still holding: with
1363
+ boxes left unchecked the gate's first reason IS the boxes, and printing both says
1364
+ one thing twice.
1365
+ """
1366
+ out = []
1367
+ for held in where.held:
1368
+ task = held.task
1369
+ bits = [task.status]
1370
+ if held.total:
1371
+ bits.append(f"{held.total - len(held.unchecked)}/{held.total} criteria")
1372
+ if not held.unchecked and held.reasons:
1373
+ # Cut at the em dash: every reason the gate gives names the fix after one,
1374
+ # and the fix is the note's business rather than this line's.
1375
+ bits.append(held.reasons[0].split(" — ")[0])
1376
+ elif not held.reasons:
1377
+ bits.append("READY TO COMPLETE")
1378
+ out.append(f"{task.name} — " + " · ".join(bits))
1379
+ for name in where.finished:
1380
+ out.append(f"{name} — complete")
1381
+ if where.loose:
1382
+ shown = ", ".join(where.loose[:3])
1383
+ more = f" and {len(where.loose) - 3} more" if len(where.loose) > 3 else ""
1384
+ out.append(f"{len(where.loose)} file(s) outside the board NOT IN GIT: "
1385
+ f"{shown}{more}")
1386
+ for cut in where.cuts:
1387
+ out.append(f"cut {cut} — every task complete, not released")
1388
+ return out
1389
+
1390
+
1275
1391
  def _first_time(cfg: dict, repo, session, what="wrap-reminded") -> bool:
1276
1392
  """Claim one of the things this session is told once, or say it is already spent.
1277
1393
 
@@ -555,6 +555,27 @@ def _observed_acs(task) -> set:
555
555
  return out
556
556
 
557
557
 
558
+ def criteria(task) -> tuple:
559
+ """(still unchecked, how many there are) over a task's acceptance criteria.
560
+
561
+ One reader for the checkboxes, because two things judge a task by them: the
562
+ completion gate REFUSES on the unchecked ones, and the end-of-turn line prints
563
+ the ratio to a person who is deciding whether the work is finished. A second
564
+ regex over the same boxes would be a second answer to whether a task is done,
565
+ and the one on screen is the one that gets believed.
566
+
567
+ A scaffold's placeholder criterion counts as neither. It is a template line
568
+ rather than an unmet promise, and refusing on it would make every task that
569
+ never edited its brief permanently incompletable — which trains people to
570
+ reach for `--accept`.
571
+ """
572
+ body = split_frontmatter((task.folder / "task.md").read_text())[1]
573
+ rows = [(mark, text) for mark, text
574
+ in re.findall(r"^\s*-\s*\[([ xX])\]\s*(.+)$", body, re.MULTILINE)
575
+ if not text.strip().startswith("<!--")]
576
+ return [text for mark, text in rows if mark == " "], len(rows)
577
+
578
+
558
579
  def _last_verify(task) -> tuple:
559
580
  """(passed, sha, when) from the newest recorded verify run, or (False, "", "")."""
560
581
  entries = as_list(task.fm.get("verified"))
@@ -602,12 +623,7 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
602
623
  """
603
624
  reasons = []
604
625
 
605
- body = split_frontmatter((task.folder / "task.md").read_text())[1]
606
- unchecked = re.findall(r"^\s*-\s*\[ \]\s*(.+)$", body, re.MULTILINE)
607
- # A scaffold's placeholder criterion is a template line, not an unmet promise —
608
- # refusing on it would mean every task that never edited its brief is
609
- # permanently incompletable, which trains people to pass `--accept`.
610
- unchecked = [u for u in unchecked if not u.strip().startswith("<!--")]
626
+ unchecked, _ = criteria(task)
611
627
  if unchecked:
612
628
  reasons.append(f"{len(unchecked)} unchecked acceptance criterion/criteria: "
613
629
  + "; ".join(u[:60] for u in unchecked[:3]))
@@ -123,9 +123,20 @@ def uncommitted_code(repo) -> list:
123
123
  this check's business.
124
124
 
125
125
  Claims and the run file are excluded for the reason they are never committed —
126
- they are one machine's coordination, true for minutes.
126
+ they are one machine's coordination, true for minutes. **So is the harness's own
127
+ scratch root**, and that exclusion was missing: the coverage shards, the id
128
+ allocator and the session markers are all written by the harness itself, and in
129
+ a repo that had not gitignored them by hand this named them as somebody's
130
+ unsaved work — then refused every completion, because a verify run drops a shard
131
+ and the gate reads this. A check that accuses a person of leaving behind a file
132
+ the harness just wrote is one nobody can act on.
127
133
  """
134
+ from .shard import DIR, state_root
135
+
128
136
  board = [r.rstrip("/") for r in (GIT.get("paths") or [])]
137
+ scratch = state_root(DIR)
138
+ if scratch:
139
+ board.append(scratch)
129
140
  code, out, _ = _git(repo, "status", "--porcelain", "-z",
130
141
  "--untracked-files=all", "--no-renames")
131
142
  if code != 0:
@@ -261,6 +261,20 @@ class Version:
261
261
  """Every task in one bucket, across the loose tier and every epic."""
262
262
  return self.tasks[name] + [t for e in self.epics for t in e.tasks[name]]
263
263
 
264
+ def finishable(self) -> bool:
265
+ """Whether every task in this cut is complete and nobody has closed it.
266
+
267
+ A cut has no status field — it is derived from its tasks, and with nothing
268
+ in flight it falls back to `planned`, which is the most misleading word
269
+ available for a cut whose work is finished. So the question is asked here,
270
+ once: the alignment sweep reports it and the end-of-turn line mentions it,
271
+ and two derivations of the same thing would eventually disagree about which
272
+ cuts are waiting on somebody.
273
+ """
274
+ tasks = self.all_tasks()
275
+ return bool(tasks) and not self.released and all(
276
+ t.status == "complete" for t in tasks)
277
+
264
278
  def status(self) -> str:
265
279
  """`released` if version.md carries a released date; `current` if any
266
280
  task is in-progress; otherwise `planned`."""
@@ -20,6 +20,26 @@ from typing import NamedTuple
20
20
  #: close a cycle.
21
21
  DIR = ".work/coverage"
22
22
 
23
+ def state_root(configured: str) -> str:
24
+ """The directory this checkout's own scratch hangs off, repo-relative.
25
+
26
+ The shards, the session markers and the id allocator all live under it, and
27
+ four docstrings in this harness call it gitignored on purpose — while nothing
28
+ made it so. So it is DERIVED from where shards are configured to land rather
29
+ than named again wherever somebody needs it: a repo that moved its shards moved
30
+ this, and a second spelling would stop matching without saying so.
31
+
32
+ Falls back to the configured directory itself when that is one segment deep.
33
+ The parent of `coverage` is the repo, and a check that excluded the repo would
34
+ be vacuous rather than wrong-looking — which is the failure that cannot be seen
35
+ from its output.
36
+ """
37
+ d = str(configured or "").replace("\\", "/").strip("/")
38
+ if not d:
39
+ return ""
40
+ return d.rsplit("/", 1)[0] if "/" in d else d
41
+
42
+
23
43
  #: Precedence when several sites claim one criterion — the SAME rank both
24
44
  #: reporters already apply within a single runner. Anything unrecognised ranks
25
45
  #: below `passed`, so a shard that learns a new status can never silently
@@ -11,12 +11,111 @@ Everything here is a thing a session cannot reliably remember and a machine can
11
11
  answer. The sharpest is the first: work that is not in git. Three tasks have been
12
12
  completed on this board with their implementation in no commit anywhere, one of them
13
13
  the task about that exact failure.
14
+
15
+ `standing` is the same knowledge at a smaller size, for the end of a turn rather than
16
+ the end of a session. It exists because a session's own summary is the thing a person
17
+ cannot use: measured against the founder, the complaint was never that the leftovers
18
+ were hidden but that the closing prose _"is more confusing than helping… doesn't really
19
+ say what's actually completed or not and where are we standing"_. So the bottom line is
20
+ derived — the bucket, the criteria, what the completion gate would still refuse on — and
21
+ the prose is left to say whatever it says.
14
22
  """
23
+ from datetime import date, timedelta
15
24
  from pathlib import Path
25
+ from typing import NamedTuple
26
+
27
+ from . import gate, git, kickoff, report
28
+ from .events import read as read_events
29
+ from .model import locate, scan
30
+ from .tree import cli, find_work_root, locate_work_root
31
+
32
+
33
+ class Held(NamedTuple):
34
+ """One item this session took, and what stands between it and complete."""
35
+ task: object
36
+ unchecked: list
37
+ total: int
38
+ reasons: list
39
+
40
+
41
+ class Standing(NamedTuple):
42
+ """Where the work stands, for a reader deciding whether this session is done."""
43
+ #: `Held` — taken here and still open.
44
+ held: list
45
+ #: Names taken here and moved to complete.
46
+ finished: list
47
+ #: Paths outside the board that are not in git.
48
+ loose: list
49
+ #: Cuts whose every task is complete and which nobody has released.
50
+ cuts: list
51
+
52
+ def anything(self) -> bool:
53
+ return bool(self.held or self.finished or self.loose or self.cuts)
54
+
55
+
56
+ #: How far back to read the board's own history when working out what THIS session
57
+ #: took. A session does not outlive a few days, and the log it is read out of grows
58
+ #: forever — so the window bounds what a per-turn hook costs in a repo with a long
59
+ #: history. A move older than this leaves its item UNNAMED rather than guessed at,
60
+ #: which is the safe direction: the line going quiet about one item costs a person a
61
+ #: question, and the line naming work that is not this session's costs it its
62
+ #: credibility. A plain date rather than one of git's fuzzy forms, because the file
63
+ #: backend compares it as text against an ISO timestamp and "3 days ago" would sort
64
+ #: above every row there.
65
+ _WINDOW_DAYS = 3
66
+
16
67
 
17
- from . import git, kickoff, report
18
- from .model import scan
19
- from .tree import cli, find_work_root
68
+ def standing(session: str, repo: Path) -> Standing:
69
+ """What this session is answerable for, and what of it is still outstanding.
70
+
71
+ **Which items are THIS session's comes out of the commit trailers**, not out of
72
+ the items' own `sessions:` lists. Both record a session id and they record
73
+ different facts: the frontmatter names every run that TOUCHED an item — including
74
+ one that only wrote its brief and never picked it up — while a `moved` trailer
75
+ names the run that put the item in the bucket it is in. Calling somebody else's
76
+ work yours, in the one line a person reads to decide whether this session is
77
+ finished, is the failure that would make the whole line worth ignoring.
78
+
79
+ Answers with the board half empty rather than failing when there is no work tree:
80
+ this runs in every repo the harness is configured in, and code that is not in git
81
+ is knowable in all of them while a board is not.
82
+
83
+ Empty everywhere is the honest answer for a session that took nothing and left a
84
+ clean tree, and the caller then says nothing at all — silence has to keep meaning
85
+ clean or it stops meaning anything.
86
+ """
87
+ # Never conditioned on whether BOARD writes commit. That setting says how the
88
+ # tree is kept; whether a session's code reached git is a question about the
89
+ # repo, and it is the one worth answering in a repo that has not switched the
90
+ # board's own commits on.
91
+ loose = git.uncommitted_code(repo)
92
+ root, _, _ = locate_work_root()
93
+ if not root or not root.is_dir():
94
+ return Standing(held=[], finished=[], loose=loose, cuts=[])
95
+
96
+ since = (date.today() - timedelta(days=_WINDOW_DAYS)).isoformat()
97
+ mine = {}
98
+ for e in read_events(root, since=since):
99
+ if e.get("event") == "moved" and e.get("by") == session and e.get("name"):
100
+ mine[e["name"]] = e.get("to")
101
+
102
+ held, finished = [], []
103
+ for name in mine:
104
+ task = locate(root, name)
105
+ # Gone from the live board entirely — filed with a released cut, or renamed
106
+ # under us. The trailer is history and history is allowed to name something
107
+ # that has moved on; the bottom line is about now.
108
+ if not task:
109
+ continue
110
+ if task.status == "complete":
111
+ finished.append(name)
112
+ elif task.status in ("in-progress", "blocked"):
113
+ unchecked, total = gate.criteria(task)
114
+ held.append(Held(task=task, unchecked=unchecked, total=total,
115
+ reasons=gate.gate(root, task)))
116
+
117
+ cuts = [v.name for v in scan(root)["versions"] if v.finishable()]
118
+ return Standing(held=held, finished=sorted(finished), loose=loose, cuts=cuts)
20
119
 
21
120
 
22
121
  def cmd_wrap(cfg: dict, args=None, repo=None) -> int:
@@ -40,14 +139,19 @@ def _uncommitted(repo) -> list:
40
139
 
41
140
  Said as a refusal to round down. "3 files uncommitted" reads as routine; a session
42
141
  that has just been told its work would be lost does not skim.
142
+
143
+ Asks whether git can answer before asking what it says, because the list itself
144
+ comes back empty from a repo git cannot read — and reporting that as "nothing
145
+ uncommitted" is the one wrong answer this section must not give.
43
146
  """
44
- code, out, _ = git._git(repo, "status", "--porcelain", "--untracked-files=all")
147
+ code, _, _ = git._git(repo, "rev-parse", "--git-dir")
45
148
  if code != 0:
46
149
  return [" code in git: cannot tell — this is not a git repo, or git did not "
47
150
  "answer. Check it yourself before you walk away."]
48
- paths = [line[3:] for line in out.splitlines() if len(line) > 3]
49
- # The board writes itself, so its own churn is not somebody's unsaved work.
50
- theirs = [p for p in paths if not p.startswith("work/")]
151
+ # One reader for what is outside the board, shared with the end-of-turn line and
152
+ # the completion gate: which paths ARE the board is configuration, and a second
153
+ # copy of that filter here read `work/` whatever the repo had said.
154
+ theirs = git.uncommitted_code(repo)
51
155
  if not theirs:
52
156
  ahead = _unpushed(repo)
53
157
  if ahead:
@@ -82,7 +186,13 @@ def _in_flight(root) -> list:
82
186
  if t.status == "in-progress"]
83
187
  if not live:
84
188
  return [" in progress: nothing — the board claims nobody is on anything."]
85
- named = "\n ".join(f"{t.name} {t.title}" for t in live)
189
+ # The ratio, because "move what finished" needs to know which of these is
190
+ # anywhere near finished, and the criteria are the only answer to that which
191
+ # does not depend on somebody's recollection.
192
+ named = "\n ".join(
193
+ f"{t.name} — {t.title}"
194
+ + (f" [{total - len(unchecked)}/{total} criteria]" if total else "")
195
+ for t in live for unchecked, total in [gate.criteria(t)])
86
196
  return [f" in progress: {len(live)} item(s). Move what finished, park what did "
87
197
  f"not — the bucket IS the status:\n {named}"]
88
198