@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.
- package/dist/bin.js +12 -3
- package/dist/bin.js.map +1 -1
- package/dist/data/mcp.mjs +11 -2
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js +4 -6
- package/dist/hooks/stop.js.map +1 -1
- package/harness/harness/align.py +3 -4
- package/harness/harness/config.py +134 -39
- package/harness/harness/gate.py +22 -6
- package/harness/harness/model.py +14 -0
- package/harness/harness/wrap.py +118 -8
- package/package.json +3 -3
|
@@ -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
|
-
"""
|
|
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
|
|
1209
|
-
moment is worse than one that never arrives —
|
|
1210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
|
package/harness/harness/gate.py
CHANGED
|
@@ -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
|
-
|
|
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]))
|
package/harness/harness/model.py
CHANGED
|
@@ -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`."""
|
package/harness/harness/wrap.py
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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,
|
|
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
|
-
|
|
49
|
-
#
|
|
50
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|