@appchy/jarvis 0.1.91 → 0.1.93

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.
@@ -1193,41 +1193,47 @@ def cmd_applies(cfg: dict, args=None, repo=None) -> int:
1193
1193
 
1194
1194
 
1195
1195
  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.
1196
+ """Where the work stands at the end of a turn, and whether to wrap this session up.
1197
+
1198
+ **Two answers, and they go to two different readers.** `headline` is one short
1199
+ block for the PERSON: the item, its bucket, its criteria, and what the completion
1200
+ gate would still refuse on. It costs the session nothing and repeats as often as
1201
+ the facts hold, because it replaces a question the founder was typing at the end
1202
+ of nearly every session — the summary a session writes for itself does not say
1203
+ what is actually complete, and this is derived rather than recalled. `note` is for
1204
+ the AGENT and is rationed to once a session: delivering it RESUMES the run so it
1205
+ can act, so a note that arrives every turn is a session that never gets to stop.
1206
+
1207
+ Two things earn the note. Context past the repo's threshold — unchanged, and it
1208
+ lands near the end of a long session — and an item this session took reaching
1209
+ complete, which is the early wrap a session that finishes with room to spare never
1210
+ used to get. They say different first lines on purpose: one is "you are running
1211
+ out of room", the other "the work you took is done".
1197
1212
 
1198
1213
  **It is handed a MEASUREMENT, never a transcript.** How many tokens a session is
1199
1214
  holding is a thing only its client can answer, and every client answers it its own
1200
1215
  way — so measuring is the client's half and it stays there. What is left is the
1201
1216
  part that is the same for all of them: the threshold, the window, the wording, the
1202
1217
  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.
1218
+ vendor's log file would be a harness that only works for that vendor. The
1219
+ measurement is now OPTIONAL: a client that cannot read its own usage still gets
1220
+ everything the tree and git can answer.
1204
1221
 
1205
1222
  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.
1223
+ whatever its own surface expects; nothing here knows what that looks like, and
1224
+ `note` may be absent while a headline is not.
1207
1225
 
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.
1226
+ Silent on every path where it cannot be sure, and silence still means clean. A
1227
+ reminder that arrives at the wrong moment is worse than one that never arrives —
1228
+ it teaches its reader to ignore the next one.
1211
1229
  """
1212
1230
  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
1231
  repo = Path(repo) if repo is not None else Path.cwd()
1226
- if not _first_time(cfg, repo, args.get("session")):
1227
- return 0
1232
+ session = args.get("session")
1228
1233
 
1229
1234
  from . import extend
1230
1235
  from .tree import cli
1236
+ from .wrap import standing
1231
1237
  spec = cfg["hooks"]["stop"]
1232
1238
  extra = extend.run(spec.get("extend"), repo)
1233
1239
  if not spec.get("enabled", True):
@@ -1236,6 +1242,30 @@ def cmd_remind(cfg: dict, args=None, repo=None) -> int:
1236
1242
  print(json.dumps({"headline": "\n".join(extra), "note": "\n".join(extra)}))
1237
1243
  return 0
1238
1244
 
1245
+ at = cfg["wrap"]["at_percent"]
1246
+ window = cfg["wrap"]["context_tokens"]
1247
+ try:
1248
+ used = int(args.get("used") or 0)
1249
+ except (TypeError, ValueError):
1250
+ used = 0
1251
+ percent = (used / window * 100) if (window and used > 0) else 0
1252
+ full = bool(at and percent >= at)
1253
+
1254
+ where = standing(session, repo) if session else None
1255
+ lines = _standing_lines(where) if where else []
1256
+ # Why the note fires, most specific first, and each spends its OWN claim: a
1257
+ # session told once that it is nearly full must still be told when the work it
1258
+ # took is finished, and neither can quietly consume the other's turn to speak.
1259
+ why = []
1260
+ if where and where.finished and _first_time(cfg, repo, session, "wrap-finished"):
1261
+ why.append(f"{_and_list(where.finished)} — the work you took — is complete, so "
1262
+ f"this session has reached its end rather than its limit.")
1263
+ if full and _first_time(cfg, repo, session):
1264
+ why.append(f"Context is {percent:.0f}% full ({used:,} of {window:,} tokens; "
1265
+ f"the threshold is {at}%).")
1266
+ if not lines and not why and not extra:
1267
+ return 0
1268
+
1239
1269
  # Null when the repo names none, and then the reminder says to wrap without naming
1240
1270
  # a command — better than sending the reader to one that resolves for nobody.
1241
1271
  command = cfg["wrap"]["command"]
@@ -1250,28 +1280,93 @@ def cmd_remind(cfg: dict, args=None, repo=None) -> int:
1250
1280
  else:
1251
1281
  last = (f"`{cli()} kickoff <task>` — the prompt that opens the next session on "
1252
1282
  "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}))
1283
+ note = ""
1284
+ if why:
1285
+ note = (
1286
+ " ".join(why) + " Finish this session cleanly while there is still room "
1287
+ "to do it well:\n"
1288
+ f" 1. `{cli()} handoff <task>`status, next step, and what you learned.\n"
1289
+ f" 2. Bring the task and epic docs to current — a stale brief is a trap "
1290
+ f"for the next session.\n"
1291
+ f" 3. Move anything finished: `{cli()} move <task> complete`.\n"
1292
+ f" 4. Park anything still open: `{cli()} ask <task> --question \"…\"`.\n"
1293
+ f" 5. {last}\n"
1294
+ + (f"`{command}` runs all five. " if command else "")
1295
+ + "Say so before you start, and if the user is mid-thought, finish their "
1296
+ "point first this is a reminder, not a stop."
1297
+ + ("\n" + "\n".join(extra) if extra else "")
1298
+ )
1299
+ elif extra:
1300
+ note = "\n".join(extra)
1301
+
1302
+ head = list(lines)
1303
+ if full:
1304
+ # Said whether or not the note fired. The note is spent after one turn and the
1305
+ # window keeps filling, so a person who saw "50%" once and nothing at 80% has
1306
+ # been told less as it got worse.
1307
+ head.append(f"{percent:.0f}% of context used ({used // 1000}k/"
1308
+ f"{window // 1000}k) — time to wrap up"
1309
+ + (f" (`{command}`)" if command else ""))
1310
+ elif why:
1311
+ head.append("the work you took is complete — time to wrap up"
1312
+ + (f" (`{command}`)" if command else ""))
1313
+ # An extension with nothing of ours to say speaks for itself, exactly as it does
1314
+ # on the path where the shipped half is switched off.
1315
+ headline = ("work: " + "\n ".join(head)) if head else "\n".join(extra)
1316
+ out = {"headline": headline}
1317
+ if note:
1318
+ out["note"] = note
1319
+ print(json.dumps(out))
1272
1320
  return 0
1273
1321
 
1274
1322
 
1323
+ def _and_list(names) -> str:
1324
+ """`a`, `a and b`, `a, b and c` — for a sentence a person reads."""
1325
+ names = list(names)
1326
+ if len(names) < 3:
1327
+ return " and ".join(names)
1328
+ return ", ".join(names[:-1]) + f" and {names[-1]}"
1329
+
1330
+
1331
+ def _standing_lines(where) -> list:
1332
+ """The bottom line, in as few words as it can be said in.
1333
+
1334
+ **What a person cannot get from a session's own summary**, which is where this
1335
+ came from: whether the work is actually complete, and where the item, its epic
1336
+ and the cut stand. So every line is derived — the bucket, the criteria ratio, the
1337
+ completion gate's own verdict — and none of it is this session's account of
1338
+ itself.
1339
+
1340
+ The criteria ratio carries the "not done" case on its own, so the gate's reason
1341
+ is only shown once they are all ticked and something else is still holding: with
1342
+ boxes left unchecked the gate's first reason IS the boxes, and printing both says
1343
+ one thing twice.
1344
+ """
1345
+ out = []
1346
+ for held in where.held:
1347
+ task = held.task
1348
+ bits = [task.status]
1349
+ if held.total:
1350
+ bits.append(f"{held.total - len(held.unchecked)}/{held.total} criteria")
1351
+ if not held.unchecked and held.reasons:
1352
+ # Cut at the em dash: every reason the gate gives names the fix after one,
1353
+ # and the fix is the note's business rather than this line's.
1354
+ bits.append(held.reasons[0].split(" — ")[0])
1355
+ elif not held.reasons:
1356
+ bits.append("READY TO COMPLETE")
1357
+ out.append(f"{task.name} — " + " · ".join(bits))
1358
+ for name in where.finished:
1359
+ out.append(f"{name} — complete")
1360
+ if where.loose:
1361
+ shown = ", ".join(where.loose[:3])
1362
+ more = f" and {len(where.loose) - 3} more" if len(where.loose) > 3 else ""
1363
+ out.append(f"{len(where.loose)} file(s) outside the board NOT IN GIT: "
1364
+ f"{shown}{more}")
1365
+ for cut in where.cuts:
1366
+ out.append(f"cut {cut} — every task complete, not released")
1367
+ return out
1368
+
1369
+
1275
1370
  def _first_time(cfg: dict, repo, session, what="wrap-reminded") -> bool:
1276
1371
  """Claim one of the things this session is told once, or say it is already spent.
1277
1372
 
@@ -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]))
@@ -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`."""
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -59,14 +59,14 @@
59
59
  "@jarvis/agents": "1.0.0",
60
60
  "@jarvis/anthropic": "1.0.0",
61
61
  "@jarvis/board": "0.1.0",
62
+ "@jarvis/data": "0.1.0",
62
63
  "@jarvis/errors": "1.0.0",
63
64
  "@jarvis/logger": "1.0.0",
64
65
  "@jarvis/rpc": "1.0.0",
65
66
  "@jarvis/types": "1.0.0",
66
67
  "@jarvis/typescript-config": "1.0.0",
67
68
  "@jarvis/ui": "0.1.0",
68
- "@jarvis/vitest-config": "1.0.0",
69
- "@jarvis/data": "0.1.0"
69
+ "@jarvis/vitest-config": "1.0.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",