@appchy/jarvis 0.1.128 → 0.1.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +16 -7
- package/dist/bin.js.map +1 -1
- package/harness/harness/autonomy.py +3 -6
- package/harness/harness/config.py +0 -8
- package/harness/harness/events.py +26 -27
- package/harness/harness/gate.py +2 -2
- package/harness/harness/generate.py +4 -0
- package/harness/harness/git.py +31 -20
- package/harness/harness/holders.cases.json +173 -0
- package/harness/harness/holders.py +72 -0
- package/harness/harness/lint.py +36 -0
- package/harness/harness/migrate.py +31 -0
- package/harness/harness/model.py +26 -0
- package/harness/harness/peers.cases.json +36 -25
- package/harness/harness/peers.py +26 -18
- package/harness/harness/report.py +6 -0
- package/harness/harness/shift.py +94 -132
- package/harness/harness/task.py +5 -11
- package/harness/harness/tree.py +17 -0
- package/harness/harness/version.py +4 -6
- package/harness/presets/appchy/PRESET.md +13 -15
- package/harness/presets/appchy/references/operations.md +1 -1
- package/harness/schema/work.config.schema.json +0 -6
- package/harness/test_work.py +228 -107
- package/harness/work.py +3 -8
- package/package.json +3 -3
|
@@ -359,13 +359,10 @@ def cmd_digest(args) -> int:
|
|
|
359
359
|
answered = names("answered")
|
|
360
360
|
refused = [e for e in rows if e["event"] == "gate-refused"]
|
|
361
361
|
released = names("released")
|
|
362
|
-
# What was WORKED ON is a task moving into in-progress
|
|
363
|
-
#
|
|
364
|
-
# moved three tasks by hand read as an idle shift, and under git a claim is not
|
|
365
|
-
# recorded at all, because a hold belongs to a machine rather than to a branch.
|
|
362
|
+
# What was WORKED ON is a task moving into in-progress — the take itself, whoever
|
|
363
|
+
# made it, a shift or a person moving it by hand.
|
|
366
364
|
worked = sorted({e["name"] for e in rows
|
|
367
|
-
if e["event"] == "
|
|
368
|
-
or (e["event"] == "moved" and e.get("to") == "in-progress")})
|
|
365
|
+
if e["event"] == "moved" and e.get("to") == "in-progress"})
|
|
369
366
|
|
|
370
367
|
print(f"DIGEST {len(rows)} event(s)"
|
|
371
368
|
+ (f" since {since}" if since else "") + "\n")
|
|
@@ -176,10 +176,6 @@ DEFAULTS = {
|
|
|
176
176
|
# default: a harness that ships with no ceiling is a harness whose safety
|
|
177
177
|
# depends on nobody having configured it.
|
|
178
178
|
"ceiling": 2,
|
|
179
|
-
# How long an instance's claim on a task is good for. Long enough that real
|
|
180
|
-
# work finishes inside it; short enough that a crashed shift's task is
|
|
181
|
-
# takeable the same day.
|
|
182
|
-
"lease_minutes": 240,
|
|
183
179
|
},
|
|
184
180
|
"graph": {
|
|
185
181
|
# The MCP server serving this repo's governance↔code graph, or null. Null is
|
|
@@ -466,9 +462,6 @@ def _validate(cfg: dict) -> None:
|
|
|
466
462
|
if not isinstance(ceiling, int) or isinstance(ceiling, bool) or not 0 <= ceiling <= 3:
|
|
467
463
|
raise ConfigError("autonomy.ceiling must be an integer 0–3 (the highest "
|
|
468
464
|
"task tier an unattended run may act on alone)")
|
|
469
|
-
lease = cfg["autonomy"]["lease_minutes"]
|
|
470
|
-
if not isinstance(lease, int) or isinstance(lease, bool) or lease < 1:
|
|
471
|
-
raise ConfigError("autonomy.lease_minutes must be a positive integer")
|
|
472
465
|
mcp = cfg["graph"]["mcp"]
|
|
473
466
|
if mcp is not None and not isinstance(mcp, str):
|
|
474
467
|
raise ConfigError("graph.mcp must be a server name or null")
|
|
@@ -657,7 +650,6 @@ def apply(cfg: dict) -> None:
|
|
|
657
650
|
gate.VERIFY = dict(cfg["verify"])
|
|
658
651
|
task.PLANS_DIR = cfg["plans"]["dir"]
|
|
659
652
|
autonomy.CEILING = cfg["autonomy"]["ceiling"]
|
|
660
|
-
shift.LEASE_MINUTES = cfg["autonomy"]["lease_minutes"]
|
|
661
653
|
# `shift` reads the ceiling it was imported with, so bind it there too — a
|
|
662
654
|
# module-level `from … import CEILING` captures the value, not the name.
|
|
663
655
|
shift.CEILING = cfg["autonomy"]["ceiling"]
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""The record of what the harness DID — a file, or the commits themselves.
|
|
2
2
|
|
|
3
|
-
**History, never state
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
**History, never state — with one answer derived from it.** Status is the bucket, a
|
|
4
|
+
version's status is derived from its tasks, a rule lives in the domain that owns it,
|
|
5
|
+
and none of that is read from here: blurring that line would rebuild the central
|
|
6
|
+
ledger `P-1` dissolved — one file that answers everything, which is one file to
|
|
7
|
+
disagree with the tree. The one exception is **who is on an item**. A take IS an
|
|
8
|
+
event, the commit that records it is the only record every machine shares, and the
|
|
9
|
+
lease that answered the question separately was a second record that disagreed — so
|
|
10
|
+
`holders` derives it from here, and nothing else does.
|
|
8
11
|
|
|
9
12
|
**Two backends, and which one is the repo's call.** With `git.commit` off — the
|
|
10
13
|
default, and what every consumer that has not asked for anything else gets — the
|
|
@@ -15,11 +18,10 @@ writes to git therefore has no `.events.jsonl` at all, which is the point — th
|
|
|
15
18
|
file records a session id rather than a machine or a branch, and it appends at the
|
|
16
19
|
end, so two active branches conflict on it every time.
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
warning under git, which is a deliberate trade rather than an oversight.
|
|
21
|
+
One kind of event never reaches git: a **refusal** changed nothing, so there is no
|
|
22
|
+
commit for it to ride on and no board history for it to be part of; `status` loses
|
|
23
|
+
its "refused twice" warning under git, which is a deliberate trade rather than an
|
|
24
|
+
oversight.
|
|
23
25
|
|
|
24
26
|
What it buys is the thing a tree cannot hold: *time*. A tree remembers only now, so
|
|
25
27
|
it cannot say what shipped overnight, how long something sat blocked, or which
|
|
@@ -44,18 +46,13 @@ from . import git
|
|
|
44
46
|
|
|
45
47
|
LOG = ".events.jsonl"
|
|
46
48
|
|
|
47
|
-
#: Events
|
|
48
|
-
#:
|
|
49
|
-
#:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
#:
|
|
53
|
-
|
|
54
|
-
#: git seam see work waiting for a commit while the tree was untouched, and report
|
|
55
|
-
#: the change as "carried by a board write that committed a moment earlier": a
|
|
56
|
-
#: refused completion, told its work was safely in git under somebody else's commit,
|
|
57
|
-
#: when nothing had been written and nothing committed.
|
|
58
|
-
UNCOMMITTED = HOLDS + ("gate-refused",)
|
|
49
|
+
#: Events no commit ever carries: a REFUSAL, which changed nothing, so there is no
|
|
50
|
+
#: commit for it to ride on. Buffering a refusal made the git seam see work waiting
|
|
51
|
+
#: for a commit while the tree was untouched, and report the change as "carried by a
|
|
52
|
+
#: board write that committed a moment earlier": a refused completion, told its work
|
|
53
|
+
#: was safely in git under somebody else's commit, when nothing had been written and
|
|
54
|
+
#: nothing committed.
|
|
55
|
+
UNCOMMITTED = ("gate-refused",)
|
|
59
56
|
|
|
60
57
|
#: What this command has done so far, waiting for the commit that carries it. Only
|
|
61
58
|
#: used under git; a process runs one command, so one buffer is one commit.
|
|
@@ -70,8 +67,6 @@ KINDS = (
|
|
|
70
67
|
"blocked", # parked, waiting on a human
|
|
71
68
|
"asked", # a question was filed against a task
|
|
72
69
|
"answered", # and resolved
|
|
73
|
-
"claimed", # an instance leased a task
|
|
74
|
-
"released-claim", # ...and gave it back, or the lease expired
|
|
75
70
|
"verified", # verify.* ran — with its exit codes
|
|
76
71
|
"observed", # an eyes-on record against an AC
|
|
77
72
|
"gate-refused", # completion was refused, and why
|
|
@@ -134,11 +129,15 @@ def pending() -> list:
|
|
|
134
129
|
return list(_PENDING)
|
|
135
130
|
|
|
136
131
|
|
|
137
|
-
def read(root: Path, since: str = "", name: str = "") -> list:
|
|
132
|
+
def read(root: Path, since: str = "", name: str = "", eventless: bool = False) -> list:
|
|
138
133
|
"""Every event, oldest first. A malformed line is skipped silently — a
|
|
139
|
-
half-written record from a killed process must not break `digest`.
|
|
134
|
+
half-written record from a killed process must not break `digest`.
|
|
135
|
+
|
|
136
|
+
`eventless` also returns, under git, each commit that recorded a run on an item
|
|
137
|
+
without recording an event — what taking an item already in progress writes. The
|
|
138
|
+
holder derivation asks for it; the activity log never shows one."""
|
|
140
139
|
if git.enabled():
|
|
141
|
-
return git.read(root.parent, since=since, name=name)
|
|
140
|
+
return git.read(root.parent, since=since, name=name, eventless=eventless)
|
|
142
141
|
p = root / LOG
|
|
143
142
|
if not p.is_file():
|
|
144
143
|
return []
|
package/harness/harness/gate.py
CHANGED
|
@@ -43,8 +43,8 @@ from .autonomy import tier_of
|
|
|
43
43
|
VERIFY: dict = {}
|
|
44
44
|
|
|
45
45
|
#: The run in flight, beside the board it is proving. Never enters git — it names a
|
|
46
|
-
#: pid on one machine at one moment,
|
|
47
|
-
#:
|
|
46
|
+
#: pid on one machine at one moment, and `git.py` drops it by filename for that
|
|
47
|
+
#: reason.
|
|
48
48
|
RUN = ".verify"
|
|
49
49
|
|
|
50
50
|
|
|
@@ -47,6 +47,10 @@ def _version_section(v: Version, root: Path) -> str:
|
|
|
47
47
|
}.get(status, status)
|
|
48
48
|
link = f"[{v.title}]({_relpath_from_readme(v.md, root)})"
|
|
49
49
|
head = f"### Version — {link} _( {badge} )_"
|
|
50
|
+
over = v.over_ceiling()
|
|
51
|
+
if over:
|
|
52
|
+
head += (f"\n\n**Over its ceiling by {over}** — {len(v.open_tasks())} open; "
|
|
53
|
+
f"a reshape is owed before it closes.")
|
|
50
54
|
if v.outcome:
|
|
51
55
|
head += f"\n\n{v.outcome}"
|
|
52
56
|
parts = []
|
package/harness/harness/git.py
CHANGED
|
@@ -27,10 +27,9 @@ Four things read that format, so it is a rule rather than a convention — see t
|
|
|
27
27
|
repo's own architecture docs. Who, when and which branch are NOT trailers: git
|
|
28
28
|
already knows them, and a second copy is a second thing to drift.
|
|
29
29
|
|
|
30
|
-
**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
paths say.
|
|
30
|
+
**A run in flight never enters git.** A `.verify` names the pid running this
|
|
31
|
+
checkout's gates — a fact about one machine at one moment, not about a branch — so it
|
|
32
|
+
is filtered out of every commit here, whatever the configured paths say.
|
|
34
33
|
|
|
35
34
|
**What a commit contains, exactly.** Only the configured paths, committed with a
|
|
36
35
|
pathspec, so a session's unrelated staged code is neither swept in nor disturbed.
|
|
@@ -56,13 +55,10 @@ GIT: dict = {"commit": False, "push": False, "remote": "origin", "paths": ["work
|
|
|
56
55
|
#: format that drifts breaks the record silently rather than loudly.
|
|
57
56
|
ITEM, EVENT, MACHINE, SESSION = "Work-Item", "Work-Event", "Work-Machine", "Work-Session"
|
|
58
57
|
|
|
59
|
-
#: Never committed, whatever `git.paths` says. A
|
|
60
|
-
#:
|
|
61
|
-
#:
|
|
62
|
-
|
|
63
|
-
#: committed one would tell every other checkout a build is running inside it.
|
|
64
|
-
CLAIM = ".claim"
|
|
65
|
-
LOCAL = frozenset({CLAIM, ".verify"})
|
|
58
|
+
#: Never committed, whatever `git.paths` says. A verify run is a fact about one box
|
|
59
|
+
#: at one moment — a pid, true for the minutes it takes — and a committed one would
|
|
60
|
+
#: tell every other checkout a build is running inside it.
|
|
61
|
+
LOCAL = frozenset({".verify"})
|
|
66
62
|
|
|
67
63
|
_NET_TIMEOUT = 120
|
|
68
64
|
_ATTEMPTS = 3
|
|
@@ -259,7 +255,7 @@ def _hold(repo):
|
|
|
259
255
|
"""Serialise the commit against other board writes on this machine.
|
|
260
256
|
|
|
261
257
|
In the git directory rather than the work tree, because it is machine-local
|
|
262
|
-
coordination and belongs
|
|
258
|
+
coordination and belongs on this machine — nowhere near a branch. A lock
|
|
263
259
|
that cannot be taken is WAITED for and then ignored: the commit is the thing
|
|
264
260
|
that matters, and blocking a board write forever to protect a lock would be the
|
|
265
261
|
lock winning over the work it exists to protect.
|
|
@@ -860,7 +856,7 @@ def _session() -> str:
|
|
|
860
856
|
_FORMAT = "%x00%H%x1f%aI%x1f%an%x1f%B"
|
|
861
857
|
|
|
862
858
|
|
|
863
|
-
def _log(repo, *extra) -> list:
|
|
859
|
+
def _log(repo, *extra, eventless=False) -> list:
|
|
864
860
|
"""Board events out of `git log`, oldest first. One parser for every caller, so
|
|
865
861
|
what `digest` reads and what a pull reports can never be two different shapes."""
|
|
866
862
|
code, out, _ = _git(repo, "log", "--reverse", f"--format={_FORMAT}", "-E",
|
|
@@ -870,22 +866,26 @@ def _log(repo, *extra) -> list:
|
|
|
870
866
|
rows = []
|
|
871
867
|
for record in out.split("\0"):
|
|
872
868
|
if record.strip():
|
|
873
|
-
rows.extend(_events(record))
|
|
869
|
+
rows.extend(_events(record, eventless))
|
|
874
870
|
return rows
|
|
875
871
|
|
|
876
872
|
|
|
877
|
-
def read(repo, since: str = "", name: str = "") -> list:
|
|
873
|
+
def read(repo, since: str = "", name: str = "", eventless: bool = False) -> list:
|
|
878
874
|
"""The board's history, oldest first, read back out of the commits.
|
|
879
875
|
|
|
880
876
|
Local: `git log` reads this clone and nothing else, so history costs no network
|
|
881
877
|
and has no rate limit. There is no index and no cache — a fact that can be
|
|
882
878
|
derived is not stored, and this one can.
|
|
879
|
+
|
|
880
|
+
`eventless` also returns each commit that recorded a run on an item without
|
|
881
|
+
recording an event, as a row whose `event` is empty — see `_events`.
|
|
883
882
|
"""
|
|
884
|
-
rows = _log(repo, *([f"--since={_since(since)}"] if since else [])
|
|
883
|
+
rows = _log(repo, *([f"--since={_since(since)}"] if since else []),
|
|
884
|
+
eventless=eventless)
|
|
885
885
|
return [r for r in rows if not name or r.get("name") == name]
|
|
886
886
|
|
|
887
887
|
|
|
888
|
-
def _events(record: str) -> list:
|
|
888
|
+
def _events(record: str, eventless: bool = False) -> list:
|
|
889
889
|
"""Every event one commit carried, back in the shape the readers expect."""
|
|
890
890
|
parts = record.split("\x1f")
|
|
891
891
|
if len(parts) < 4:
|
|
@@ -894,11 +894,22 @@ def _events(record: str) -> list:
|
|
|
894
894
|
trailers = _trailers(message)
|
|
895
895
|
item = (trailers.get(ITEM) or [""])[0]
|
|
896
896
|
kinds = trailers.get(EVENT) or []
|
|
897
|
-
if not item or not kinds:
|
|
898
|
-
return []
|
|
899
|
-
details = _details(message, kinds)
|
|
900
897
|
who = (trailers.get(SESSION) or [""])[0]
|
|
901
898
|
host = (trailers.get(MACHINE) or [""])[0]
|
|
899
|
+
if not item:
|
|
900
|
+
return []
|
|
901
|
+
if not kinds:
|
|
902
|
+
# Absent from the activity log on purpose (see `_message`), and handed only to
|
|
903
|
+
# a reader that asks: recording a run on an item already in progress writes
|
|
904
|
+
# exactly this commit, and to the question of who is on an item it is a take.
|
|
905
|
+
if not (eventless and who):
|
|
906
|
+
return []
|
|
907
|
+
row = {"ts": _utc(when), "event": "", "name": item, "sha": sha[:12],
|
|
908
|
+
"author": author, "by": who}
|
|
909
|
+
if host:
|
|
910
|
+
row["machine"] = host
|
|
911
|
+
return [row]
|
|
912
|
+
details = _details(message, kinds)
|
|
902
913
|
out = []
|
|
903
914
|
# One pool per kind, drawn from in order, so the second `verified` in a commit
|
|
904
915
|
# gets the second body line rather than the first's.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
{
|
|
2
|
+
"why": [
|
|
3
|
+
"Who is on an item is derived from commit trailers, by two readers in two languages: the harness",
|
|
4
|
+
"(`harness/holders.py`, over what `git._events` parses) and the board (`packages/board/src/holders.ts`,",
|
|
5
|
+
"over what `board.git.ts` reads). Two parsers and two derivations of one answer is exactly where the",
|
|
6
|
+
"terminal and the board come to name different holders for the same item, so both suites run THESE",
|
|
7
|
+
"commits and assert THIS answer. A drift is a red test in both.",
|
|
8
|
+
"",
|
|
9
|
+
"Each case is an item's board history, oldest first, as commits. `expect` maps an item to its holders",
|
|
10
|
+
"in order: the session that took it (or, with none, who git says made the commit), the machine, and",
|
|
11
|
+
"which holder that take followed, when it followed one."
|
|
12
|
+
],
|
|
13
|
+
"cases": [
|
|
14
|
+
{
|
|
15
|
+
"name": "a move into progress is a take",
|
|
16
|
+
"commits": [
|
|
17
|
+
{
|
|
18
|
+
"sha": "a1",
|
|
19
|
+
"when": "2026-09-12T10:00:00+03:00",
|
|
20
|
+
"author": "Danny",
|
|
21
|
+
"message": "docs(work): moved thing\n\nmoved from=queue · to=in-progress\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"expect": {
|
|
25
|
+
"thing": [
|
|
26
|
+
{ "session": "11111111-1111-4111-8111-111111111111", "machine": "box-a", "tookOverFrom": null }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "creating an item, verifying it and moving it elsewhere are its history, not a claim on it",
|
|
32
|
+
"commits": [
|
|
33
|
+
{
|
|
34
|
+
"sha": "b1",
|
|
35
|
+
"when": "2026-09-12T10:00:00+03:00",
|
|
36
|
+
"author": "Danny",
|
|
37
|
+
"message": "docs(work): created thing\n\ncreated epic=an-epic\n\nWork-Item: thing\nWork-Event: created\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"sha": "b2",
|
|
41
|
+
"when": "2026-09-12T11:00:00+03:00",
|
|
42
|
+
"author": "Danny",
|
|
43
|
+
"message": "docs(work): moved thing\n\nmoved from=in-progress · to=complete\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"expect": {}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "recording a run on an item already in progress is a take, and it follows the first",
|
|
50
|
+
"commits": [
|
|
51
|
+
{
|
|
52
|
+
"sha": "c1",
|
|
53
|
+
"when": "2026-09-12T10:00:00+03:00",
|
|
54
|
+
"author": "Danny",
|
|
55
|
+
"message": "docs(work): moved thing\n\nmoved from=queue · to=in-progress\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"sha": "c2",
|
|
59
|
+
"when": "2026-09-12T12:00:00+03:00",
|
|
60
|
+
"author": "Danny",
|
|
61
|
+
"message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-b\nWork-Session: 22222222-2222-4222-8222-222222222222\n"
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"expect": {
|
|
65
|
+
"thing": [
|
|
66
|
+
{ "session": "11111111-1111-4111-8111-111111111111", "machine": "box-a", "tookOverFrom": null },
|
|
67
|
+
{
|
|
68
|
+
"session": "22222222-2222-4222-8222-222222222222",
|
|
69
|
+
"machine": "box-b",
|
|
70
|
+
"tookOverFrom": "11111111-1111-4111-8111-111111111111"
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "renewing your own take is not a second holder, and keeps the place you first took",
|
|
77
|
+
"commits": [
|
|
78
|
+
{
|
|
79
|
+
"sha": "d1",
|
|
80
|
+
"when": "2026-09-12T10:00:00+03:00",
|
|
81
|
+
"author": "Danny",
|
|
82
|
+
"message": "docs(work): moved thing\n\nmoved from=queue · to=in-progress\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"sha": "d2",
|
|
86
|
+
"when": "2026-09-12T11:00:00+03:00",
|
|
87
|
+
"author": "Danny",
|
|
88
|
+
"message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-b\nWork-Session: 22222222-2222-4222-8222-222222222222\n"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"sha": "d3",
|
|
92
|
+
"when": "2026-09-12T12:00:00+03:00",
|
|
93
|
+
"author": "Danny",
|
|
94
|
+
"message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"expect": {
|
|
98
|
+
"thing": [
|
|
99
|
+
{ "session": "11111111-1111-4111-8111-111111111111", "machine": "box-a", "tookOverFrom": null },
|
|
100
|
+
{
|
|
101
|
+
"session": "22222222-2222-4222-8222-222222222222",
|
|
102
|
+
"machine": "box-b",
|
|
103
|
+
"tookOverFrom": "11111111-1111-4111-8111-111111111111"
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"name": "a take that names no run is still a take, by whoever git says made it",
|
|
110
|
+
"commits": [
|
|
111
|
+
{
|
|
112
|
+
"sha": "e1",
|
|
113
|
+
"when": "2026-09-12T10:00:00+03:00",
|
|
114
|
+
"author": "A Person",
|
|
115
|
+
"message": "docs(work): moved thing\n\nmoved from=queue · to=in-progress\n\nWork-Item: thing\nWork-Event: moved\nWork-Machine: box-a\n"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"sha": "e2",
|
|
119
|
+
"when": "2026-09-12T11:00:00+03:00",
|
|
120
|
+
"author": "Danny",
|
|
121
|
+
"message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-b\nWork-Session: 22222222-2222-4222-8222-222222222222\n"
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
"expect": {
|
|
125
|
+
"thing": [
|
|
126
|
+
{ "session": "", "machine": "box-a", "tookOverFrom": null },
|
|
127
|
+
{
|
|
128
|
+
"session": "22222222-2222-4222-8222-222222222222",
|
|
129
|
+
"machine": "box-b",
|
|
130
|
+
"tookOverFrom": "A Person"
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"name": "a commit carrying neither a run nor an event is somebody's hand edit, not a take",
|
|
137
|
+
"commits": [
|
|
138
|
+
{
|
|
139
|
+
"sha": "f1",
|
|
140
|
+
"when": "2026-09-12T10:00:00+03:00",
|
|
141
|
+
"author": "Danny",
|
|
142
|
+
"message": "docs(work): changed thing\n\nWork-Item: thing\nWork-Machine: box-a\n"
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
"expect": {}
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"name": "two items in one history keep separate holders",
|
|
149
|
+
"commits": [
|
|
150
|
+
{
|
|
151
|
+
"sha": "g1",
|
|
152
|
+
"when": "2026-09-12T10:00:00+03:00",
|
|
153
|
+
"author": "Danny",
|
|
154
|
+
"message": "docs(work): moved one\n\nmoved from=queue · to=in-progress\n\nWork-Item: one\nWork-Event: moved\nWork-Machine: box-a\nWork-Session: 11111111-1111-4111-8111-111111111111\n"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"sha": "g2",
|
|
158
|
+
"when": "2026-09-12T11:00:00+03:00",
|
|
159
|
+
"author": "Danny",
|
|
160
|
+
"message": "docs(work): moved two\n\nmoved from=queue · to=in-progress\n\nWork-Item: two\nWork-Event: moved\nWork-Machine: box-b\nWork-Session: 22222222-2222-4222-8222-222222222222\n"
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
"expect": {
|
|
164
|
+
"one": [
|
|
165
|
+
{ "session": "11111111-1111-4111-8111-111111111111", "machine": "box-a", "tookOverFrom": null }
|
|
166
|
+
],
|
|
167
|
+
"two": [
|
|
168
|
+
{ "session": "22222222-2222-4222-8222-222222222222", "machine": "box-b", "tookOverFrom": null }
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Who is on an item, and who was — derived from the commits that took it.
|
|
2
|
+
|
|
3
|
+
The same derivation the board's TypeScript runs (`packages/board/src/holders.ts`),
|
|
4
|
+
because the terminal and the board are two doors onto one answer. They disagreed on
|
|
5
|
+
exactly the case this settles: a take on an item already in progress changes no
|
|
6
|
+
bucket, so a reader that counted only moves hid the second taker the board was naming.
|
|
7
|
+
Both run `holders.cases.json`, so a drift between the two languages is a red test in
|
|
8
|
+
both suites rather than two names for one holder.
|
|
9
|
+
|
|
10
|
+
Nothing is stored and nothing expires. A take is a moment, so how long ago is derived
|
|
11
|
+
by whoever asks, and whether that session is still there is `peers`'s question.
|
|
12
|
+
|
|
13
|
+
Pure: it reads rows and returns dicts, so both event backends feed it the same way.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_holders(rows: list) -> list:
|
|
18
|
+
"""Everyone who has taken ONE item, in the order they first took it.
|
|
19
|
+
|
|
20
|
+
`rows` is that item's history, oldest first, as `events.read(..., eventless=True)`
|
|
21
|
+
returns it. Two shapes count as taking it, and nothing else does: a move INTO
|
|
22
|
+
in-progress, and a commit for this item carrying a run but no event — what
|
|
23
|
+
recording a session writes, and the only record a take on an item already in
|
|
24
|
+
progress leaves. `created`, `verified` and the rest are this item's history
|
|
25
|
+
without being a claim on it.
|
|
26
|
+
|
|
27
|
+
One entry per session, carrying its latest take, so renewing your own work is not
|
|
28
|
+
a second holder standing beside you. A take that followed somebody else's names
|
|
29
|
+
them, so an override is visible without anybody having flagged it.
|
|
30
|
+
"""
|
|
31
|
+
takes, commits = [], set()
|
|
32
|
+
for row in rows:
|
|
33
|
+
sha = row.get("sha")
|
|
34
|
+
if sha and sha in commits:
|
|
35
|
+
continue
|
|
36
|
+
moved = row.get("event") == "moved" and row.get("to") == "in-progress"
|
|
37
|
+
recorded = not row.get("event") and bool(row.get("by"))
|
|
38
|
+
if not (moved or recorded):
|
|
39
|
+
continue
|
|
40
|
+
if sha:
|
|
41
|
+
commits.add(sha)
|
|
42
|
+
takes.append({"session": str(row.get("by", "")),
|
|
43
|
+
"machine": str(row.get("machine", "")),
|
|
44
|
+
"by": str(row.get("author", "")),
|
|
45
|
+
"taken": str(row.get("ts", ""))})
|
|
46
|
+
|
|
47
|
+
# Keyed by session, falling back to who git says made it, and a later take
|
|
48
|
+
# replaces an earlier one IN PLACE — a dict keeps where a key was first set,
|
|
49
|
+
# which is what the TypeScript side's Map does, so both list holders in one order.
|
|
50
|
+
distinct = {}
|
|
51
|
+
for take in takes:
|
|
52
|
+
distinct[take["session"] or take["by"]] = take
|
|
53
|
+
holders = list(distinct.values())
|
|
54
|
+
|
|
55
|
+
for previous, current in zip(holders, holders[1:]):
|
|
56
|
+
current["took_over_from"] = previous["session"] or previous["by"]
|
|
57
|
+
return holders
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def to_holders_by_item(rows: list) -> dict:
|
|
61
|
+
"""Everyone on every item a history names, in one pass — `{item: [holder, ...]}`.
|
|
62
|
+
|
|
63
|
+
The board is read as a page of items, so this is the question both readers ask.
|
|
64
|
+
Rows arrive oldest first and keep that order within each item, because an
|
|
65
|
+
override is derived from the order of the takes.
|
|
66
|
+
"""
|
|
67
|
+
by_item = {}
|
|
68
|
+
for row in rows:
|
|
69
|
+
name = row.get("name")
|
|
70
|
+
if name:
|
|
71
|
+
by_item.setdefault(name, []).append(row)
|
|
72
|
+
return {name: to_holders(history) for name, history in by_item.items()}
|
package/harness/harness/lint.py
CHANGED
|
@@ -12,6 +12,7 @@ from .tree import (
|
|
|
12
12
|
TASK_REGION_CAP,
|
|
13
13
|
)
|
|
14
14
|
from .frontmatter import as_list, parse_frontmatter, split_frontmatter
|
|
15
|
+
from . import tree as _tree
|
|
15
16
|
from .model import locate_feature, scan, scan_features, scan_filed
|
|
16
17
|
from .registry import code_vocabulary, locate_domain
|
|
17
18
|
|
|
@@ -193,6 +194,41 @@ def _shape_lint(root: Path, s: dict) -> list:
|
|
|
193
194
|
f"fits — or, if this goal genuinely needs only these, say why in "
|
|
194
195
|
f"`one_goal:`")
|
|
195
196
|
|
|
197
|
+
# 3c — a FAT epic: more open tasks than an epic's ceiling. The other end of the
|
|
198
|
+
# floor above and the same concept — an epic is a size a person can plan
|
|
199
|
+
# together. OPEN tasks only: an epic whose work has mostly shipped is not hard
|
|
200
|
+
# to plan, and counting finished work would warn on every long-lived goal
|
|
201
|
+
# about tasks nobody is still doing. Same skips as the floor, same escape.
|
|
202
|
+
#
|
|
203
|
+
# Splitting an epic changes no cut's total, so this bound can never make the
|
|
204
|
+
# cut ceiling worse. It buys a readable plan, not a release that closes.
|
|
205
|
+
for e in [e for v in s["versions"] for e in v.epics]:
|
|
206
|
+
if not e.planned or e.done_tier:
|
|
207
|
+
continue
|
|
208
|
+
n = sum(t.status in ACTIVE for t in e.all_tasks())
|
|
209
|
+
if n > _tree.EPIC_TASK_CEILING and not e.one_goal:
|
|
210
|
+
warns.append(
|
|
211
|
+
f"epic {e.name}: {n} open task(s) — past {_tree.EPIC_TASK_CEILING} an "
|
|
212
|
+
f"epic is no longer a goal you plan together. Split it along its "
|
|
213
|
+
f"goals, or, if this size is deliberate, say why in `one_goal:`")
|
|
214
|
+
|
|
215
|
+
# 3d — a FULL cut: more open tasks, summed across its epics, than a cut's
|
|
216
|
+
# ceiling. Flagged — never refused and never routed. Where a task belongs is
|
|
217
|
+
# a planning call, and a harness that moved work out of a cut on its own
|
|
218
|
+
# would be deciding which half of a goal ships. So this names what is owed
|
|
219
|
+
# and the two ways out, and says whose call it is. Printed after every board
|
|
220
|
+
# write, so whoever files into a full cut reads it on the spot. Epic COUNT is
|
|
221
|
+
# deliberately not bounded; only the sum is.
|
|
222
|
+
for v in s["versions"]:
|
|
223
|
+
over = v.over_ceiling()
|
|
224
|
+
if over:
|
|
225
|
+
warns.append(
|
|
226
|
+
f"cut {v.name}: {len(v.open_tasks())} open tasks, {over} past its "
|
|
227
|
+
f"ceiling of {_tree.CUT_TASK_CEILING} — a reshape is owed so it does "
|
|
228
|
+
f"not close half-built: move work to a later cut, or reshape an epic. "
|
|
229
|
+
f"That is a person's call; an agent parks it. If the size is "
|
|
230
|
+
f"deliberate, say why in `one_goal:` in its version.md")
|
|
231
|
+
|
|
196
232
|
# 3b — a task inside an epic carrying its own plan.md. The design is settled
|
|
197
233
|
# ONCE in `epic.md` §Plan; a second design doc per task is the
|
|
198
234
|
# re-analyse-per-task cost the tier exists to remove. Warn, don't block:
|
|
@@ -46,11 +46,26 @@ def _uncommitted(repo: Path, work: Path) -> list:
|
|
|
46
46
|
if len(line) <= 3:
|
|
47
47
|
continue
|
|
48
48
|
p = (repo / line[3:].strip().strip('"')).resolve()
|
|
49
|
+
# A leftover lease file is untracked by design and is exactly what this
|
|
50
|
+
# migration removes, so it cannot also be the reason it refuses to run.
|
|
51
|
+
if p.name == STALE_CLAIM:
|
|
52
|
+
continue
|
|
49
53
|
if work.resolve() == p or work.resolve() in p.parents:
|
|
50
54
|
dirty.append(str(p.relative_to(repo)))
|
|
51
55
|
return sorted(dirty)
|
|
52
56
|
|
|
53
57
|
|
|
58
|
+
#: The file the deleted lease left beside a task. Nothing reads or writes one any
|
|
59
|
+
#: more and git no longer filters it out of a commit, so a leftover would be committed
|
|
60
|
+
#: by the next board write in its folder unless a migration removes it first.
|
|
61
|
+
STALE_CLAIM = ".claim"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _stale_claims(root: Path) -> list:
|
|
65
|
+
"""Every leftover lease file under the work tree."""
|
|
66
|
+
return sorted(root.rglob(STALE_CLAIM))
|
|
67
|
+
|
|
68
|
+
|
|
54
69
|
def _archived_cuts(root: Path) -> list:
|
|
55
70
|
"""Cuts sitting in the archive's former home, `work/archive/versions/<cut>/`.
|
|
56
71
|
|
|
@@ -81,6 +96,7 @@ def cmd_migrate(args) -> int:
|
|
|
81
96
|
archive = archive_dir(root)
|
|
82
97
|
complete = complete_dir(root)
|
|
83
98
|
cuts = _archived_cuts(root)
|
|
99
|
+
stale = _stale_claims(root)
|
|
84
100
|
|
|
85
101
|
# The hard refusals. Two of anything means somebody has already started this,
|
|
86
102
|
# or a repo has grown a second copy — and merging two trees is a judgement
|
|
@@ -104,6 +120,8 @@ def cmd_migrate(args) -> int:
|
|
|
104
120
|
todo.append("versions/archive/ is created")
|
|
105
121
|
if not complete.is_dir():
|
|
106
122
|
todo.append("versions/complete/ is created")
|
|
123
|
+
if stale:
|
|
124
|
+
todo.append(f"{len(stale)} leftover lease file(s) are removed")
|
|
107
125
|
if not todo:
|
|
108
126
|
print(f"{rel(root, root)} is already on the current layout — nothing to do")
|
|
109
127
|
return 0
|
|
@@ -116,6 +134,8 @@ def cmd_migrate(args) -> int:
|
|
|
116
134
|
print(f" {sum(1 for _ in old.rglob('task.md'))} task(s) would move")
|
|
117
135
|
for c in cuts:
|
|
118
136
|
print(f" archived: {c.name}")
|
|
137
|
+
for p in stale:
|
|
138
|
+
print(f" leftover lease file: {rel(p, root)}")
|
|
119
139
|
return 0
|
|
120
140
|
|
|
121
141
|
dirty = _uncommitted(repo, root)
|
|
@@ -182,6 +202,17 @@ def cmd_migrate(args) -> int:
|
|
|
182
202
|
elif stale_home.is_dir() and not any(stale_home.iterdir()):
|
|
183
203
|
stale_home.rmdir()
|
|
184
204
|
|
|
205
|
+
# Found again rather than reused: moving the backlog above carries any leftover
|
|
206
|
+
# inside it to a new path, and that branch reuses the name `stale` for its own list.
|
|
207
|
+
leftovers = _stale_claims(root)
|
|
208
|
+
if leftovers:
|
|
209
|
+
# Removed rather than moved: a lease said who held an item for a few hours,
|
|
210
|
+
# the commit history answers that for good now, and nothing reads one.
|
|
211
|
+
for p in leftovers:
|
|
212
|
+
p.unlink()
|
|
213
|
+
print(f"removed {len(leftovers)} leftover lease file(s) — who is on an item is "
|
|
214
|
+
f"read from the commit history now")
|
|
215
|
+
|
|
185
216
|
print("done — commit this, then every machine working this repo needs a CLI "
|
|
186
217
|
"new enough to read it")
|
|
187
218
|
return 0
|