@appchy/jarvis 0.1.93 → 0.1.95
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 +1 -1
- package/harness/harness/config.py +30 -3
- package/harness/harness/git.py +12 -1
- package/harness/harness/shard.py +20 -0
- package/harness/harness/wrap.py +24 -17
- package/harness/test_work.py +268 -1
- package/harness/work.py +4 -3
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -10154,7 +10154,7 @@ import { createRequire as createRequire2 } from "module";
|
|
|
10154
10154
|
var _require = createRequire2(import.meta.url);
|
|
10155
10155
|
var VERSION2 = _require("../package.json").version ?? "0.0.0";
|
|
10156
10156
|
var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
|
|
10157
|
-
var SHA = "
|
|
10157
|
+
var SHA = "20f7aad";
|
|
10158
10158
|
var BUILT = "2026-09-11";
|
|
10159
10159
|
var BUILD = SHA ?? "source";
|
|
10160
10160
|
var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -1263,7 +1281,10 @@ def cmd_remind(cfg: dict, args=None, repo=None) -> int:
|
|
|
1263
1281
|
if full and _first_time(cfg, repo, session):
|
|
1264
1282
|
why.append(f"Context is {percent:.0f}% full ({used:,} of {window:,} tokens; "
|
|
1265
1283
|
f"the threshold is {at}%).")
|
|
1266
|
-
|
|
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:
|
|
1267
1288
|
return 0
|
|
1268
1289
|
|
|
1269
1290
|
# Null when the repo names none, and then the reminder says to wrap without naming
|
|
@@ -1364,6 +1385,12 @@ def _standing_lines(where) -> list:
|
|
|
1364
1385
|
f"{shown}{more}")
|
|
1365
1386
|
for cut in where.cuts:
|
|
1366
1387
|
out.append(f"cut {cut} — every task complete, not released")
|
|
1388
|
+
# Said LAST and only beside something else. A repo git cannot read has nothing
|
|
1389
|
+
# actionable to say about commits, so on its own this would be a line every turn
|
|
1390
|
+
# that nobody can act on — while next to work in flight it is the difference
|
|
1391
|
+
# between "your code is committed" and "nobody checked".
|
|
1392
|
+
if where.loose is None and out:
|
|
1393
|
+
out.append("code in git: cannot tell — git did not answer in this directory")
|
|
1367
1394
|
return out
|
|
1368
1395
|
|
|
1369
1396
|
|
package/harness/harness/git.py
CHANGED
|
@@ -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:
|
package/harness/harness/shard.py
CHANGED
|
@@ -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
|
package/harness/harness/wrap.py
CHANGED
|
@@ -20,7 +20,7 @@ say what's actually completed or not and where are we standing"_. So the bottom
|
|
|
20
20
|
derived — the bucket, the criteria, what the completion gate would still refuse on — and
|
|
21
21
|
the prose is left to say whatever it says.
|
|
22
22
|
"""
|
|
23
|
-
from datetime import
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
from typing import NamedTuple
|
|
26
26
|
|
|
@@ -44,14 +44,12 @@ class Standing(NamedTuple):
|
|
|
44
44
|
held: list
|
|
45
45
|
#: Names taken here and moved to complete.
|
|
46
46
|
finished: list
|
|
47
|
-
#: Paths outside the board that are not in git
|
|
47
|
+
#: Paths outside the board that are not in git — None when git could not answer,
|
|
48
|
+
#: which is not the same as none of them.
|
|
48
49
|
loose: list
|
|
49
50
|
#: Cuts whose every task is complete and which nobody has released.
|
|
50
51
|
cuts: list
|
|
51
52
|
|
|
52
|
-
def anything(self) -> bool:
|
|
53
|
-
return bool(self.held or self.finished or self.loose or self.cuts)
|
|
54
|
-
|
|
55
53
|
|
|
56
54
|
#: How far back to read the board's own history when working out what THIS session
|
|
57
55
|
#: took. A session does not outlive a few days, and the log it is read out of grows
|
|
@@ -88,16 +86,24 @@ def standing(session: str, repo: Path) -> Standing:
|
|
|
88
86
|
# tree is kept; whether a session's code reached git is a question about the
|
|
89
87
|
# repo, and it is the one worth answering in a repo that has not switched the
|
|
90
88
|
# board's own commits on.
|
|
91
|
-
|
|
89
|
+
#
|
|
90
|
+
# None when git cannot answer, which is NOT the same as nothing outstanding —
|
|
91
|
+
# the list comes back empty from a repo git cannot read, and an empty list here
|
|
92
|
+
# would read as "your code is safe". The caller says which it got.
|
|
93
|
+
loose = git.uncommitted_code(repo) if git.is_repo(repo) else None
|
|
92
94
|
root, _, _ = locate_work_root()
|
|
93
95
|
if not root or not root.is_dir():
|
|
94
96
|
return Standing(held=[], finished=[], loose=loose, cuts=[])
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
# UTC, because that is the clock the rows are on: git anchors a plain date to
|
|
99
|
+
# UTC midnight and the file backend compares it as text against a UTC
|
|
100
|
+
# timestamp, so a local date would move the window by this machine's offset.
|
|
101
|
+
since = (datetime.now(timezone.utc).date()
|
|
102
|
+
- timedelta(days=_WINDOW_DAYS)).isoformat()
|
|
103
|
+
# Where it went is deliberately not kept: the bucket the item is in NOW is the
|
|
104
|
+
# status, and a remembered destination would be a second answer to it.
|
|
105
|
+
mine = {e["name"] for e in read_events(root, since=since)
|
|
106
|
+
if e.get("event") == "moved" and e.get("by") == session and e.get("name")}
|
|
101
107
|
|
|
102
108
|
held, finished = [], []
|
|
103
109
|
for name in mine:
|
|
@@ -144,8 +150,7 @@ def _uncommitted(repo) -> list:
|
|
|
144
150
|
comes back empty from a repo git cannot read — and reporting that as "nothing
|
|
145
151
|
uncommitted" is the one wrong answer this section must not give.
|
|
146
152
|
"""
|
|
147
|
-
|
|
148
|
-
if code != 0:
|
|
153
|
+
if not git.is_repo(repo):
|
|
149
154
|
return [" code in git: cannot tell — this is not a git repo, or git did not "
|
|
150
155
|
"answer. Check it yourself before you walk away."]
|
|
151
156
|
# One reader for what is outside the board, shared with the end-of-turn line and
|
|
@@ -189,10 +194,12 @@ def _in_flight(root) -> list:
|
|
|
189
194
|
# The ratio, because "move what finished" needs to know which of these is
|
|
190
195
|
# anywhere near finished, and the criteria are the only answer to that which
|
|
191
196
|
# does not depend on somebody's recollection.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
197
|
+
rows = []
|
|
198
|
+
for t in live:
|
|
199
|
+
unchecked, total = gate.criteria(t)
|
|
200
|
+
ratio = f" [{total - len(unchecked)}/{total} criteria]" if total else ""
|
|
201
|
+
rows.append(f"{t.name} — {t.title}{ratio}")
|
|
202
|
+
named = "\n ".join(rows)
|
|
196
203
|
return [f" in progress: {len(live)} item(s). Move what finished, park what did "
|
|
197
204
|
f"not — the bucket IS the status:\n {named}"]
|
|
198
205
|
|
package/harness/test_work.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
B-/AC- id bookkeeping, no stories.md) and the version/epic/task reshape — the
|
|
4
4
|
three-tier scan, the version gate, and the four shape lints."""
|
|
5
5
|
|
|
6
|
+
import copy
|
|
6
7
|
import importlib.util
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
@@ -27,7 +28,7 @@ from harness.ids import LEDGER as L # noqa: E402 — fixtures render in the rep
|
|
|
27
28
|
from harness import (align, architecture, autonomy, branches, config, coverage, epic, # noqa: E402
|
|
28
29
|
events, extend, frontmatter, gate, generate, git, ids, kickoff,
|
|
29
30
|
lint, model, peers, registry, report, safety, scaffold,
|
|
30
|
-
shard, shift, task, tree, version)
|
|
31
|
+
shard, shift, task, tree, version, wrap)
|
|
31
32
|
|
|
32
33
|
# `parse_argv` is the ENTRY's own concern, so it is loaded from work.py by path
|
|
33
34
|
# rather than re-homed into a module just to make a test tidier.
|
|
@@ -7021,6 +7022,272 @@ def test_asking_where_the_gates_stand_never_commits_the_board():
|
|
|
7021
7022
|
config.apply(config.DEFAULTS)
|
|
7022
7023
|
|
|
7023
7024
|
|
|
7025
|
+
# --- where the work stands, at the end of a turn ------------------------------
|
|
7026
|
+
# The stop judge answered one question — is this session nearly full — and a
|
|
7027
|
+
# session that finished early was told nothing, so the founder asked instead, at
|
|
7028
|
+
# the end of nearly every session. Measured on this machine before it was
|
|
7029
|
+
# changed: 18 of the last 32 sessions in this repo crossed the threshold and were
|
|
7030
|
+
# reminded, 14 ended under it and heard nothing, six of those within 60k tokens
|
|
7031
|
+
# of the line. What they asked for is not the leftovers count but the bottom
|
|
7032
|
+
# line — "doesn't really say what's actually completed or not and where are we
|
|
7033
|
+
# standing" — so every case below asserts a DERIVED fact and none of them
|
|
7034
|
+
# asserts prose.
|
|
7035
|
+
|
|
7036
|
+
|
|
7037
|
+
def _stop_repo(tmp, bucket="queue", criteria="- [x] the first thing\n- [ ] the second thing\n"):
|
|
7038
|
+
"""A git-mode repo with one cut, one epic and one item in `bucket`."""
|
|
7039
|
+
repo = _git_repo(tmp, push=False)
|
|
7040
|
+
e = repo / "work" / "versions" / "01-a-cut" / "an-epic"
|
|
7041
|
+
(e / bucket / "alpha").mkdir(parents=True)
|
|
7042
|
+
(e / "epic.md").write_text("---\ntype: epic\n---\n\n# E\n")
|
|
7043
|
+
(e.parent / "version.md").write_text(
|
|
7044
|
+
"---\ncreated: 2026-08-01\norder: 1\noutcome: x\n---\n\n# Cut\n")
|
|
7045
|
+
(e / bucket / "alpha" / "task.md").write_text(
|
|
7046
|
+
"---\npriority: P0\n---\n\n# Alpha\n\n## Acceptance criteria\n\n" + criteria)
|
|
7047
|
+
_git(repo, "add", "-A")
|
|
7048
|
+
_git(repo, "commit", "-qm", "the board so far")
|
|
7049
|
+
return repo
|
|
7050
|
+
|
|
7051
|
+
|
|
7052
|
+
def _stop_cfg(**wrap):
|
|
7053
|
+
cfg = copy.deepcopy(config.DEFAULTS)
|
|
7054
|
+
cfg["wrap"].update(wrap)
|
|
7055
|
+
cfg["git"] = {"commit": True, "push": False, "remote": "origin", "paths": ["work"]}
|
|
7056
|
+
return cfg
|
|
7057
|
+
|
|
7058
|
+
|
|
7059
|
+
def _remind(repo, cfg, session=None, used=0) -> dict:
|
|
7060
|
+
"""What the stop judge printed, parsed — or None when it said nothing at all."""
|
|
7061
|
+
args = {}
|
|
7062
|
+
if session:
|
|
7063
|
+
args["session"] = session
|
|
7064
|
+
if used:
|
|
7065
|
+
args["used"] = used
|
|
7066
|
+
with _work_dir(str(repo / "work")):
|
|
7067
|
+
out = _capture_stdout(lambda: config.cmd_remind(cfg, args, repo))
|
|
7068
|
+
return json.loads(out) if out.strip() else None
|
|
7069
|
+
|
|
7070
|
+
|
|
7071
|
+
def _moved_by(repo, name, to, session, folder_from=None, folder_to=None):
|
|
7072
|
+
"""Land a real board commit for a move, trailered with `session`.
|
|
7073
|
+
|
|
7074
|
+
The trailer is what says WHOSE move it was, so the id has to arrive the way the
|
|
7075
|
+
seam reads it — off the environment — rather than as an argument no caller
|
|
7076
|
+
passes.
|
|
7077
|
+
"""
|
|
7078
|
+
keep = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
7079
|
+
instance = os.environ.pop("WORK_INSTANCE", None)
|
|
7080
|
+
os.environ["CLAUDE_CODE_SESSION_ID"] = session
|
|
7081
|
+
try:
|
|
7082
|
+
if folder_from and folder_to:
|
|
7083
|
+
folder_to.parent.mkdir(parents=True, exist_ok=True)
|
|
7084
|
+
shutil.move(str(folder_from), str(folder_to))
|
|
7085
|
+
_board_write(repo, name, [{"event": "moved", "name": name, "to": to}])
|
|
7086
|
+
finally:
|
|
7087
|
+
if keep is None:
|
|
7088
|
+
os.environ.pop("CLAUDE_CODE_SESSION_ID", None)
|
|
7089
|
+
else:
|
|
7090
|
+
os.environ["CLAUDE_CODE_SESSION_ID"] = keep
|
|
7091
|
+
if instance is not None:
|
|
7092
|
+
os.environ["WORK_INSTANCE"] = instance
|
|
7093
|
+
|
|
7094
|
+
|
|
7095
|
+
def test_the_line_says_where_the_item_this_session_took_actually_stands():
|
|
7096
|
+
# The bottom line, derived: which item, which bucket, and how much of it is
|
|
7097
|
+
# actually done. A session's own summary is the thing that cannot answer this
|
|
7098
|
+
# — it is written by the same run whose progress is in question.
|
|
7099
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7100
|
+
try:
|
|
7101
|
+
repo = _stop_repo(tmp)
|
|
7102
|
+
e = repo / "work" / "versions" / "01-a-cut" / "an-epic"
|
|
7103
|
+
_moved_by(repo, "alpha", "in-progress", "sid-mine",
|
|
7104
|
+
folder_from=e / "queue" / "alpha",
|
|
7105
|
+
folder_to=e / "in-progress" / "alpha")
|
|
7106
|
+
said = _remind(repo, _stop_cfg(), session="sid-mine")
|
|
7107
|
+
assert said, "an item taken here and unfinished must be said"
|
|
7108
|
+
assert "alpha" in said["headline"]
|
|
7109
|
+
assert "in-progress" in said["headline"]
|
|
7110
|
+
assert "1/2 criteria" in said["headline"], said["headline"]
|
|
7111
|
+
# Nothing has earned resuming the run: delivering a note continues the
|
|
7112
|
+
# conversation, so an unfinished item is the person's to read, not a
|
|
7113
|
+
# reason to set the session working again.
|
|
7114
|
+
assert "note" not in said, said
|
|
7115
|
+
finally:
|
|
7116
|
+
events._PENDING.clear()
|
|
7117
|
+
config.apply(config.DEFAULTS)
|
|
7118
|
+
|
|
7119
|
+
|
|
7120
|
+
def test_an_item_another_session_took_is_never_called_yours():
|
|
7121
|
+
# The one lie that would make the whole line ignorable. An item's `sessions:`
|
|
7122
|
+
# list names every run that ever touched it — including one that only wrote its
|
|
7123
|
+
# brief — so the moved TRAILER is read instead, which names the run that put it
|
|
7124
|
+
# in the bucket it is in.
|
|
7125
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7126
|
+
try:
|
|
7127
|
+
repo = _stop_repo(tmp)
|
|
7128
|
+
e = repo / "work" / "versions" / "01-a-cut" / "an-epic"
|
|
7129
|
+
_moved_by(repo, "alpha", "in-progress", "sid-somebody-else",
|
|
7130
|
+
folder_from=e / "queue" / "alpha",
|
|
7131
|
+
folder_to=e / "in-progress" / "alpha")
|
|
7132
|
+
said = _remind(repo, _stop_cfg(), session="sid-mine")
|
|
7133
|
+
assert not said or "alpha" not in said["headline"], said
|
|
7134
|
+
finally:
|
|
7135
|
+
events._PENDING.clear()
|
|
7136
|
+
config.apply(config.DEFAULTS)
|
|
7137
|
+
|
|
7138
|
+
|
|
7139
|
+
def test_code_left_out_of_git_is_named_with_no_measurement_at_all():
|
|
7140
|
+
# Whatever the context level, including none: the usage number used to be the
|
|
7141
|
+
# only question asked, so a client that could not read its own transcript got
|
|
7142
|
+
# silence — while the sharpest thing a machine can say needs no measurement.
|
|
7143
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7144
|
+
try:
|
|
7145
|
+
repo = _stop_repo(tmp)
|
|
7146
|
+
(repo / "src").mkdir()
|
|
7147
|
+
(repo / "src" / "shipped.ts").write_text("export const it = 1;\n")
|
|
7148
|
+
said = _remind(repo, _stop_cfg(), session="sid-mine", used=0)
|
|
7149
|
+
assert said, "uncommitted code is knowable with no measurement"
|
|
7150
|
+
assert "NOT IN GIT" in said["headline"]
|
|
7151
|
+
assert "src/shipped.ts" in said["headline"]
|
|
7152
|
+
assert "note" not in said, "the person reads this; the run is not resumed"
|
|
7153
|
+
finally:
|
|
7154
|
+
events._PENDING.clear()
|
|
7155
|
+
config.apply(config.DEFAULTS)
|
|
7156
|
+
|
|
7157
|
+
|
|
7158
|
+
def test_a_cut_with_every_task_complete_says_it_is_finishable():
|
|
7159
|
+
# `planned` is what a finished cut reads as, because status is derived from
|
|
7160
|
+
# tasks in flight and there are none. Whoever is standing there at the end of a
|
|
7161
|
+
# turn is the person who can close it.
|
|
7162
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7163
|
+
try:
|
|
7164
|
+
repo = _stop_repo(tmp, bucket="complete")
|
|
7165
|
+
said = _remind(repo, _stop_cfg(), session="sid-mine")
|
|
7166
|
+
assert said, "a finishable cut must be said"
|
|
7167
|
+
assert "01-a-cut" in said["headline"]
|
|
7168
|
+
assert "not released" in said["headline"], said["headline"]
|
|
7169
|
+
finally:
|
|
7170
|
+
events._PENDING.clear()
|
|
7171
|
+
config.apply(config.DEFAULTS)
|
|
7172
|
+
|
|
7173
|
+
|
|
7174
|
+
def test_a_session_that_left_nothing_behind_is_told_nothing():
|
|
7175
|
+
# Silence has to keep meaning clean. A line that also appears when there is
|
|
7176
|
+
# nothing to say is one nobody can read anything out of.
|
|
7177
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7178
|
+
try:
|
|
7179
|
+
repo = _stop_repo(tmp)
|
|
7180
|
+
assert _remind(repo, _stop_cfg(), session="sid-mine", used=1000) is None
|
|
7181
|
+
finally:
|
|
7182
|
+
events._PENDING.clear()
|
|
7183
|
+
config.apply(config.DEFAULTS)
|
|
7184
|
+
|
|
7185
|
+
|
|
7186
|
+
def test_finishing_the_work_wraps_the_session_early_and_only_once():
|
|
7187
|
+
# The gap the measurement found: a session that finishes with room to spare
|
|
7188
|
+
# crosses no threshold, so it was never asked to close itself out. Completing
|
|
7189
|
+
# what it took is the one end-of-work signal that is certain rather than
|
|
7190
|
+
# guessed at — and it is spent after one turn, because delivering a note
|
|
7191
|
+
# resumes the run.
|
|
7192
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7193
|
+
try:
|
|
7194
|
+
repo = _git_repo(tmp, push=False)
|
|
7195
|
+
e = _provable_task(repo)
|
|
7196
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
7197
|
+
try:
|
|
7198
|
+
with _work_dir(str(repo / "work")):
|
|
7199
|
+
assert gate.cmd_verify({"task": "alpha"}) == 0
|
|
7200
|
+
_moved_by(repo, "alpha", "complete", "sid-mine",
|
|
7201
|
+
folder_from=e / "in-progress" / "alpha",
|
|
7202
|
+
folder_to=e / "complete" / "alpha")
|
|
7203
|
+
said = _remind(repo, _stop_cfg(), session="sid-mine", used=1000)
|
|
7204
|
+
assert said, "finishing the work must reach the session"
|
|
7205
|
+
assert "note" in said, said
|
|
7206
|
+
assert "complete" in said["note"]
|
|
7207
|
+
assert "Context is" not in said["note"], \
|
|
7208
|
+
"this is the end of the work, not the end of the room"
|
|
7209
|
+
assert "alpha — complete" in said["headline"], said["headline"]
|
|
7210
|
+
|
|
7211
|
+
again = _remind(repo, _stop_cfg(), session="sid-mine", used=1000)
|
|
7212
|
+
assert again and "note" not in again, \
|
|
7213
|
+
"a note every turn is a session that never gets to stop"
|
|
7214
|
+
assert "alpha — complete" in again["headline"], \
|
|
7215
|
+
"the fact holds, so the person keeps being able to read it"
|
|
7216
|
+
finally:
|
|
7217
|
+
gate.VERIFY = old
|
|
7218
|
+
finally:
|
|
7219
|
+
events._PENDING.clear()
|
|
7220
|
+
config.apply(config.DEFAULTS)
|
|
7221
|
+
|
|
7222
|
+
|
|
7223
|
+
def test_running_out_of_room_and_finishing_the_work_do_not_sound_the_same():
|
|
7224
|
+
# Two triggers, one message, and a reader who has to know which one fired:
|
|
7225
|
+
# "you are running out of room" and "the work you took is done" call for
|
|
7226
|
+
# different next moves.
|
|
7227
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7228
|
+
try:
|
|
7229
|
+
repo = _stop_repo(tmp)
|
|
7230
|
+
cfg = _stop_cfg(at_percent=50, context_tokens=1000,
|
|
7231
|
+
command="/wrap-it-up")
|
|
7232
|
+
said = _remind(repo, cfg, session="sid-full", used=800)
|
|
7233
|
+
assert said and "note" in said
|
|
7234
|
+
assert "80% of context used" in said["headline"]
|
|
7235
|
+
assert "Context is 80% full" in said["note"]
|
|
7236
|
+
assert "/wrap-it-up" in said["headline"]
|
|
7237
|
+
|
|
7238
|
+
# Spent, and the headline still says it — the window keeps filling, and
|
|
7239
|
+
# somebody who saw 50% once and nothing at 80% was told less as it got
|
|
7240
|
+
# worse.
|
|
7241
|
+
again = _remind(repo, cfg, session="sid-full", used=900)
|
|
7242
|
+
assert again and "note" not in again
|
|
7243
|
+
assert "90% of context used" in again["headline"]
|
|
7244
|
+
|
|
7245
|
+
# Below the line it says nothing about room at all.
|
|
7246
|
+
quiet = _remind(repo, cfg, session="sid-roomy", used=100)
|
|
7247
|
+
assert quiet is None, quiet
|
|
7248
|
+
finally:
|
|
7249
|
+
events._PENDING.clear()
|
|
7250
|
+
config.apply(config.DEFAULTS)
|
|
7251
|
+
|
|
7252
|
+
|
|
7253
|
+
def test_the_harness_own_scratch_is_never_somebody_elses_unsaved_work():
|
|
7254
|
+
# Four docstrings in this harness call `.work/` gitignored on purpose and
|
|
7255
|
+
# nothing made it so. In a repo that had not added the line by hand, the
|
|
7256
|
+
# harness reported its OWN session markers and coverage shards as a person's
|
|
7257
|
+
# uncommitted work — and then refused every completion, because a verify run
|
|
7258
|
+
# drops a shard and the completion gate reads this same list.
|
|
7259
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7260
|
+
try:
|
|
7261
|
+
repo = _stop_repo(tmp)
|
|
7262
|
+
(repo / ".work" / "coverage").mkdir(parents=True)
|
|
7263
|
+
(repo / ".work" / "coverage" / "vitest-w-s.json").write_text("{}\n")
|
|
7264
|
+
assert git.uncommitted_code(repo) == [], git.uncommitted_code(repo)
|
|
7265
|
+
|
|
7266
|
+
# Derived from where shards are configured to land, never a spelling of
|
|
7267
|
+
# its own: a repo that moves them moves what counts as scratch.
|
|
7268
|
+
config.apply({**_stop_cfg(), "coverage": {"shard": "tmp/evidence/shards"}})
|
|
7269
|
+
assert git.uncommitted_code(repo) == [".work/coverage/vitest-w-s.json"]
|
|
7270
|
+
finally:
|
|
7271
|
+
config.apply(config.DEFAULTS)
|
|
7272
|
+
|
|
7273
|
+
|
|
7274
|
+
def test_the_stop_hook_being_switched_off_silences_the_standing_too():
|
|
7275
|
+
# One switch for the whole hook, and it has to cover what was added to it —
|
|
7276
|
+
# a repo that switched this off would otherwise find half of it back.
|
|
7277
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7278
|
+
try:
|
|
7279
|
+
repo = _stop_repo(tmp)
|
|
7280
|
+
(repo / "src").mkdir()
|
|
7281
|
+
(repo / "src" / "shipped.ts").write_text("export const it = 1;\n")
|
|
7282
|
+
cfg = _stop_cfg()
|
|
7283
|
+
cfg["hooks"]["stop"] = {"enabled": False, "extend": []}
|
|
7284
|
+
assert _remind(repo, cfg, session="sid-mine", used=999999) is None
|
|
7285
|
+
finally:
|
|
7286
|
+
events._PENDING.clear()
|
|
7287
|
+
config.apply(config.DEFAULTS)
|
|
7288
|
+
|
|
7289
|
+
|
|
7290
|
+
|
|
7024
7291
|
if __name__ == "__main__":
|
|
7025
7292
|
tests = [v for k, v in sorted(globals().items())
|
|
7026
7293
|
if k.startswith("test_") and callable(v)]
|
package/harness/work.py
CHANGED
|
@@ -64,9 +64,10 @@ Subcommands:
|
|
|
64
64
|
config set <key> <value> [--json] write one dotted key, validated first
|
|
65
65
|
config unset <key> drop an override, back to the default
|
|
66
66
|
context [--project <dir>] the SessionStart pointers, all config-derived
|
|
67
|
-
remind --used <tokens> [--session <id>]
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
remind [--used <tokens>] [--session <id>] where the work stands, and whether
|
|
68
|
+
to wrap up. One JSON object, or nothing. The
|
|
69
|
+
measurement is OPTIONAL and is its client's to
|
|
70
|
+
answer; the tree and git need none
|
|
70
71
|
applies --file <path> [--session <id>] what a session must be told now that
|
|
71
72
|
it is about to write this file — the judgements
|
|
72
73
|
no gate catches, and which system it is in
|