@appchy/jarvis 0.1.103 → 0.1.105
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/check.py +94 -0
- package/harness/harness/drift.py +66 -16
- package/harness/harness/gate.py +11 -0
- package/harness/harness/task.py +28 -0
- package/harness/test_work.py +188 -8
- package/harness/work.py +7 -2
- package/package.json +4 -4
package/dist/bin.js
CHANGED
|
@@ -10260,7 +10260,7 @@ import { createRequire as createRequire2 } from "module";
|
|
|
10260
10260
|
var _require = createRequire2(import.meta.url);
|
|
10261
10261
|
var VERSION2 = _require("../package.json").version ?? "0.0.0";
|
|
10262
10262
|
var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
|
|
10263
|
-
var SHA = "
|
|
10263
|
+
var SHA = "1bf7cfd";
|
|
10264
10264
|
var BUILT = "2026-09-11";
|
|
10265
10265
|
var BUILD = SHA ?? "source";
|
|
10266
10266
|
var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Read the item against the repo, once per session, before the work resumes.
|
|
2
|
+
|
|
3
|
+
Every lock in this repo is on the exit. The eight verify commands, the evidence
|
|
4
|
+
check and the completion refusals all fire at `complete`, by which point the work is
|
|
5
|
+
spent — so a session that picked up a brief describing a world that ended finds out
|
|
6
|
+
last. This is the one door at the other end.
|
|
7
|
+
|
|
8
|
+
**It checks the ITEM, not the edit**, and that is what makes it possible at all. The
|
|
9
|
+
earlier framing — check the CHANGE before the first file is written — could not be
|
|
10
|
+
built: the map's scope call takes prose, so nothing tied a check to anything on the
|
|
11
|
+
board, and every way of inferring it was worse than the problem. A check whose input
|
|
12
|
+
is the item has the attribution for free. (Founder, 2026-09-12.)
|
|
13
|
+
|
|
14
|
+
**Once per session per item.** Not once per item: a fresh session continuing old work
|
|
15
|
+
is exactly when a brief has had time to go stale and nobody has looked. Not once per
|
|
16
|
+
session: a session that picks up a second item has read nothing about it.
|
|
17
|
+
|
|
18
|
+
**It reports; it never refuses on what it finds.** A brief can drift because somebody
|
|
19
|
+
else renamed a file, and making your completion wait on cleaning up their change is a
|
|
20
|
+
tax on whoever finishes next. What IS refused at completion is the check never having
|
|
21
|
+
run — the session is answerable for having looked, never for what the looking found.
|
|
22
|
+
(Founder, 2026-09-12.)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .config import load as load_config
|
|
28
|
+
from .drift import describe, drift
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _state(root: Path):
|
|
32
|
+
"""Where the per-session record lives — the same gitignored root the wrap
|
|
33
|
+
reminder and the rules-delivery markers already use, reached through the same
|
|
34
|
+
helper so a second answer cannot drift from theirs."""
|
|
35
|
+
from .config import _session_state
|
|
36
|
+
repo = root.parent
|
|
37
|
+
return _session_state(load_config(repo), repo)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _marker(root: Path, item: str, session: str) -> Path:
|
|
41
|
+
return _state(root) / "checked" / item / (session or "unknown")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def documents(root: Path, task) -> list:
|
|
45
|
+
"""What this item claims: its own brief, and the epic plan it executes against.
|
|
46
|
+
|
|
47
|
+
The epic is in because a task deliberately does NOT restate the design — that
|
|
48
|
+
lives in `epic.md` §Plan and is what the task is built against, so a path that
|
|
49
|
+
died there misleads exactly as much as one in the brief.
|
|
50
|
+
"""
|
|
51
|
+
docs = []
|
|
52
|
+
brief = task.folder / "task.md"
|
|
53
|
+
if brief.is_file():
|
|
54
|
+
docs.append(str(brief.relative_to(root.parent)))
|
|
55
|
+
epic = task.folder.parent.parent / "epic.md"
|
|
56
|
+
if epic.is_file():
|
|
57
|
+
docs.append(str(epic.relative_to(root.parent)))
|
|
58
|
+
return docs
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run(root: Path, task, session: str) -> list:
|
|
62
|
+
"""Check the item and record that this session did. Returns the findings.
|
|
63
|
+
|
|
64
|
+
The record is written whether or not anything was found — it says the looking
|
|
65
|
+
happened, which is the only thing the completion gate asks about.
|
|
66
|
+
"""
|
|
67
|
+
findings = drift(root.parent, documents(root, task))
|
|
68
|
+
marker = _marker(root, task.name, session)
|
|
69
|
+
try:
|
|
70
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
marker.write_text("")
|
|
72
|
+
except OSError:
|
|
73
|
+
pass # unwritable state means the gate will ask again, which is the safe way to be wrong
|
|
74
|
+
return findings
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def checked(root: Path, task, session: str) -> bool:
|
|
78
|
+
"""Has THIS session read THIS item against the repo?
|
|
79
|
+
|
|
80
|
+
A session id we never got is treated as checked. The alternative is refusing
|
|
81
|
+
every completion that arrives without one — a plain terminal, a script, another
|
|
82
|
+
agent — over a record none of them could ever have written.
|
|
83
|
+
"""
|
|
84
|
+
if not session:
|
|
85
|
+
return True
|
|
86
|
+
return _marker(root, task.name, session).exists()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def report(root: Path, task, session: str) -> str:
|
|
90
|
+
"""The lines a pickup prints. Named separately from `run` so the gate can ask
|
|
91
|
+
whether the looking happened without printing a second copy of it."""
|
|
92
|
+
findings = run(root, task, session)
|
|
93
|
+
head = f" read {task.name} against the repo:"
|
|
94
|
+
return f"{head}\n{describe(findings)}"
|
package/harness/harness/drift.py
CHANGED
|
@@ -80,6 +80,26 @@ def _roots(owner: str) -> list:
|
|
|
80
80
|
return ["", here, "work", "apps/cli"]
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _moved_to(path: str, by_base: dict) -> str:
|
|
84
|
+
"""Where this file went, or "" when that cannot be said with confidence.
|
|
85
|
+
|
|
86
|
+
**A basename alone is far too weak to propose a move from.** `epic.md` matches
|
|
87
|
+
seventeen files here, `task.md` hundreds — and picking the first of them named a
|
|
88
|
+
completely unrelated epic's plan as the new home of a released one, which a
|
|
89
|
+
reader who trusts it would have edited their brief to point at. Worse than
|
|
90
|
+
silence. So a candidate must also carry the named path's own parent directory,
|
|
91
|
+
and if more than one still qualifies, none is offered: "seventeen candidates" is
|
|
92
|
+
not an answer, and neither is the first of them sorted. Reported by jarvis-14
|
|
93
|
+
after `check` did exactly this on their sweep item, 2026-09-12.
|
|
94
|
+
"""
|
|
95
|
+
parts = Path(path).parts
|
|
96
|
+
if len(parts) < 2:
|
|
97
|
+
return "" # a bare filename names no place it could have moved FROM
|
|
98
|
+
tail = str(Path(*parts[-2:]))
|
|
99
|
+
fits = [c for c in by_base.get(parts[-1], []) if c == tail or c.endswith("/" + tail)]
|
|
100
|
+
return fits[0] if len(fits) == 1 else ""
|
|
101
|
+
|
|
102
|
+
|
|
83
103
|
def _resolve(path: str, owner: str, tracked: set) -> str:
|
|
84
104
|
for root in _roots(owner):
|
|
85
105
|
candidate = str(Path(root) / path) if root else path
|
|
@@ -95,21 +115,51 @@ def _resolve(path: str, owner: str, tracked: set) -> str:
|
|
|
95
115
|
BUILT = {"dist", "build", ".next", "out", "node_modules", ".data", ".work"}
|
|
96
116
|
|
|
97
117
|
|
|
98
|
-
def
|
|
118
|
+
def _siblings(repo: Path) -> set:
|
|
119
|
+
"""Repos sitting beside this one. A brief writes `gotcha/apps/web/src/...` or
|
|
120
|
+
"gotcha's `apps/backoffice/src/lib/experiment.ts`" as often as it writes
|
|
121
|
+
`../gotcha/...`, and none of those are this checkout's to judge — but only the
|
|
122
|
+
`../` spelling announces itself. Found by hand-checking a random twelve of this
|
|
123
|
+
checker's own findings, where two of the twelve were a neighbour's file.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
return {d.name for d in repo.parent.iterdir()
|
|
127
|
+
if d.is_dir() and (d / ".git").exists() and d.name != repo.name}
|
|
128
|
+
except OSError:
|
|
129
|
+
return set()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _unjudgeable(path: str, siblings=frozenset()) -> bool:
|
|
99
133
|
"""Paths this cannot have an opinion about, kept apart from paths it says are
|
|
100
134
|
fine — silence here means *not my question*, not *checked and good*.
|
|
101
135
|
|
|
102
|
-
|
|
103
|
-
any machine that has built.
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
Three kinds. Build output, which git never tracks and which is present anyway on
|
|
137
|
+
any machine that has built. Anything climbing out of the repo with `../`. And
|
|
138
|
+
anything rooted at a sibling repo's name, which is the same thing said without
|
|
139
|
+
the `../` and is otherwise indistinguishable from one of ours.
|
|
106
140
|
"""
|
|
107
|
-
|
|
141
|
+
parts = Path(path).parts
|
|
142
|
+
if path.startswith("../") or (BUILT & set(parts)):
|
|
143
|
+
return True
|
|
144
|
+
return bool(parts) and parts[0] in siblings
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
#: A sentence asserting that the path is NOT there. The absence is then the claim,
|
|
148
|
+
#: and flagging it pushes somebody into deleting the most informative half of a
|
|
149
|
+
#: correct statement — J-41's `enforced_by` reads "`apps/ws/src/storage.ts` does not
|
|
150
|
+
#: exist", which is the rule's whole point. Reported by jarvis-14, 2026-09-12.
|
|
151
|
+
ABSENT = re.compile(
|
|
152
|
+
r"\b(does not exist|no longer exists?|never existed|is gone|was (deleted|removed)|"
|
|
153
|
+
r"deleted|removed|retired|there is no such|nothing (at|called)|no longer there)\b", re.I)
|
|
108
154
|
|
|
109
155
|
|
|
110
156
|
def named_paths(text: str):
|
|
111
|
-
"""Every path a document names, with the line it is on
|
|
112
|
-
|
|
157
|
+
"""Every path a document names, with the line it is on and that line's text.
|
|
158
|
+
|
|
159
|
+
Deduplicated per line so one path written twice in a sentence is one finding.
|
|
160
|
+
The line rides along because whether a path is a CLAIM depends on the sentence
|
|
161
|
+
around it: a brief saying a file does not exist is right, not stale.
|
|
162
|
+
"""
|
|
113
163
|
seen = set()
|
|
114
164
|
for n, line in enumerate(text.splitlines(), 1):
|
|
115
165
|
for m in NAMED.finditer(line):
|
|
@@ -117,7 +167,7 @@ def named_paths(text: str):
|
|
|
117
167
|
if key in seen:
|
|
118
168
|
continue
|
|
119
169
|
seen.add(key)
|
|
120
|
-
yield m.group(1), n
|
|
170
|
+
yield m.group(1), n, line
|
|
121
171
|
|
|
122
172
|
|
|
123
173
|
def drift(repo: Path, docs, tracked=None) -> list:
|
|
@@ -130,6 +180,7 @@ def drift(repo: Path, docs, tracked=None) -> list:
|
|
|
130
180
|
that question.
|
|
131
181
|
"""
|
|
132
182
|
tracked = _tracked(repo) if tracked is None else tracked
|
|
183
|
+
siblings = _siblings(Path(repo))
|
|
133
184
|
by_base = defaultdict(list)
|
|
134
185
|
for t in tracked:
|
|
135
186
|
by_base[Path(t).name].append(t)
|
|
@@ -141,16 +192,15 @@ def drift(repo: Path, docs, tracked=None) -> list:
|
|
|
141
192
|
text = (repo / rel).read_text(errors="ignore")
|
|
142
193
|
except OSError:
|
|
143
194
|
continue
|
|
144
|
-
for path, line in named_paths(text):
|
|
145
|
-
if "/" not in path or _unjudgeable(path):
|
|
195
|
+
for path, line, text_of_line in named_paths(text):
|
|
196
|
+
if "/" not in path or _unjudgeable(path, siblings):
|
|
146
197
|
continue # no location to check, or not this repo's to answer for
|
|
147
198
|
if _resolve(path, rel, tracked):
|
|
148
199
|
continue
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
else
|
|
153
|
-
found.append((rel, line, path, "gone", ""))
|
|
200
|
+
if ABSENT.search(text_of_line):
|
|
201
|
+
continue # the sentence says it is not there; that is the claim, not a stale one
|
|
202
|
+
where = _moved_to(path, by_base)
|
|
203
|
+
found.append((rel, line, path, "moved" if where else "gone", where))
|
|
154
204
|
return found
|
|
155
205
|
|
|
156
206
|
|
package/harness/harness/gate.py
CHANGED
|
@@ -623,6 +623,17 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
|
|
|
623
623
|
"""
|
|
624
624
|
reasons = []
|
|
625
625
|
|
|
626
|
+
# Answerable for having LOOKED, never for what the looking found. The check
|
|
627
|
+
# reports drift and refuses nothing over it — a brief goes stale because of
|
|
628
|
+
# somebody else's rename as often as your own, and taxing whoever finishes next
|
|
629
|
+
# for that is the shape of defect this gate exists to stop, not to add.
|
|
630
|
+
from . import check as _check
|
|
631
|
+
from .model import current_session_id as _sid
|
|
632
|
+
if not _check.checked(root, task, _sid()):
|
|
633
|
+
reasons.append(f"this session has not read {task.name} against the repo — "
|
|
634
|
+
f"`jarvis work check {task.name}`. It reports what the brief "
|
|
635
|
+
f"names that is no longer here; nothing is refused over what it finds")
|
|
636
|
+
|
|
626
637
|
unchecked, _ = criteria(task)
|
|
627
638
|
if unchecked:
|
|
628
639
|
reasons.append(f"{len(unchecked)} unchecked acceptance criterion/criteria: "
|
package/harness/harness/task.py
CHANGED
|
@@ -357,6 +357,17 @@ def cmd_move(args) -> int:
|
|
|
357
357
|
events.append(root, "moved", name, **{"from": task.status, "to": to})
|
|
358
358
|
print(f"moved '{name}': {task.status}/ -> {to}/")
|
|
359
359
|
|
|
360
|
+
# Picking it up is the moment to find out the brief describes something that is
|
|
361
|
+
# no longer here — cheap to act on now, and the alternative is finding out by
|
|
362
|
+
# walking into it. Reported, never refused: a path can die because of somebody
|
|
363
|
+
# else's rename, and completion is not the place to pay for that.
|
|
364
|
+
if to == "in-progress":
|
|
365
|
+
from . import check as _check
|
|
366
|
+
from .model import current_session_id as _sid
|
|
367
|
+
moved_task = locate(root, name)
|
|
368
|
+
if moved_task:
|
|
369
|
+
print(_check.report(root, moved_task, _sid()))
|
|
370
|
+
|
|
360
371
|
# No handoff is scaffolded on pickup — `handoff.md` is created on demand via
|
|
361
372
|
# `jarvis work handoff` only when a task actually hands across conversations
|
|
362
373
|
# (a blank scaffold on every pickup was noise). Working checklists live in
|
|
@@ -377,6 +388,23 @@ def cmd_move(args) -> int:
|
|
|
377
388
|
|
|
378
389
|
_sync(root)
|
|
379
390
|
return 0
|
|
391
|
+
def cmd_check(args) -> int:
|
|
392
|
+
"""Read an item against the repo and say where the two have come apart.
|
|
393
|
+
|
|
394
|
+
The same thing `move <name> in-progress` prints, reachable on its own — because
|
|
395
|
+
the completion gate asks whether this session has looked, and a gate naming a
|
|
396
|
+
command that does not exist is the trap this cut exists to remove.
|
|
397
|
+
"""
|
|
398
|
+
from . import check as _check
|
|
399
|
+
root = find_work_root()
|
|
400
|
+
name = args["name"]
|
|
401
|
+
task = locate(root, name)
|
|
402
|
+
if not task:
|
|
403
|
+
die(missing(root, name))
|
|
404
|
+
print(_check.report(root, task, current_session_id()))
|
|
405
|
+
return 0
|
|
406
|
+
|
|
407
|
+
|
|
380
408
|
def cmd_handoff(args) -> int:
|
|
381
409
|
root = find_work_root()
|
|
382
410
|
name = args["name"]
|
package/harness/test_work.py
CHANGED
|
@@ -25,6 +25,12 @@ tempfile.tempdir = _TMP
|
|
|
25
25
|
|
|
26
26
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
27
|
from harness.ids import LEDGER as L # noqa: E402 — fixtures render in the repo's dialect
|
|
28
|
+
|
|
29
|
+
# A suite that inherits the developer's own session id answers differently depending
|
|
30
|
+
# on who ran it — and it does now, because the completion gate asks whether THIS
|
|
31
|
+
# session read the item against the repo. Cleared once here; the handful of tests
|
|
32
|
+
# that are about session identity set it themselves and put it back.
|
|
33
|
+
os.environ.pop("CLAUDE_CODE_SESSION_ID", None)
|
|
28
34
|
from harness import (align, architecture, autonomy, branches, config, coverage, epic, # noqa: E402
|
|
29
35
|
events, extend, frontmatter, gate, generate, git, ids, kickoff,
|
|
30
36
|
lint, model, peers, registry, report, safety, scaffold,
|
|
@@ -7260,9 +7266,11 @@ def test_a_finished_cut_is_ready_rather_than_planned_and_goes_back_when_reopened
|
|
|
7260
7266
|
# Adding `ready` to the derivation took the whole README generator down
|
|
7261
7267
|
# through a bare dict lookup, which is a lot of blast radius for a label.
|
|
7262
7268
|
assert "ready" in generate._version_section(cut, root)
|
|
7263
|
-
# `
|
|
7264
|
-
# to printing an unknown state rather than
|
|
7265
|
-
|
|
7269
|
+
# `jarvis work list` renders the same status through its own map in
|
|
7270
|
+
# `report`; both now fall back to printing an unknown state rather than
|
|
7271
|
+
# raising. Not asserted here — it prints rather than returns, and a test
|
|
7272
|
+
# that captures stdout to prove a dict has a key is worth less than the
|
|
7273
|
+
# sentence saying both were changed together.
|
|
7266
7274
|
|
|
7267
7275
|
# Reopened: straight back to current, with nothing to undo by hand.
|
|
7268
7276
|
(v / "in-progress").mkdir(exist_ok=True)
|
|
@@ -7493,9 +7501,11 @@ def test_a_brief_naming_a_file_that_is_gone_is_reported():
|
|
|
7493
7501
|
assert [(f[2], f[3]) for f in found] == [("src/vanished.ts", "gone")]
|
|
7494
7502
|
|
|
7495
7503
|
|
|
7496
|
-
def
|
|
7497
|
-
#
|
|
7498
|
-
#
|
|
7504
|
+
def test_a_basename_match_under_a_different_parent_is_gone_not_moved():
|
|
7505
|
+
# This test used to assert the opposite, and asserted a bug: a file sharing only
|
|
7506
|
+
# its name with something elsewhere is not evidence of where it went. Proposing
|
|
7507
|
+
# a destination from that is a guess a reader would act on. Only a candidate
|
|
7508
|
+
# carrying the named path's own parent earns the word "moved".
|
|
7499
7509
|
from harness.drift import drift
|
|
7500
7510
|
with tempfile.TemporaryDirectory() as tmp:
|
|
7501
7511
|
repo = _git_repo(tmp, push=False)
|
|
@@ -7508,8 +7518,8 @@ def test_a_file_that_merely_moved_is_reported_as_moved_and_says_where():
|
|
|
7508
7518
|
|
|
7509
7519
|
found = drift(repo, ["work/brief.md"])
|
|
7510
7520
|
assert len(found) == 1
|
|
7511
|
-
assert found[0][3] == "
|
|
7512
|
-
assert found[0][4] == "
|
|
7521
|
+
assert found[0][3] == "gone"
|
|
7522
|
+
assert found[0][4] == ""
|
|
7513
7523
|
|
|
7514
7524
|
|
|
7515
7525
|
def test_a_path_written_the_way_a_writer_means_it_is_not_a_finding():
|
|
@@ -7576,6 +7586,176 @@ def test_a_brief_that_still_describes_the_repo_says_so_rather_than_saying_nothin
|
|
|
7576
7586
|
assert "still describes the repo" in describe([])
|
|
7577
7587
|
|
|
7578
7588
|
|
|
7589
|
+
def test_completing_asks_whether_this_session_read_the_item_against_the_repo():
|
|
7590
|
+
# The one thing this refuses: not having looked. Never what the looking found.
|
|
7591
|
+
import harness.gate as gate, harness.model as model, harness.check as check
|
|
7592
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7593
|
+
repo = _git_repo(tmp, push=False)
|
|
7594
|
+
root = repo / "work"
|
|
7595
|
+
v = root / "versions" / "26-cut"
|
|
7596
|
+
t = v / "an-epic" / "in-progress" / "alpha"
|
|
7597
|
+
t.mkdir(parents=True)
|
|
7598
|
+
(v / "version.md").write_text(
|
|
7599
|
+
"---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
|
|
7600
|
+
(v / "an-epic" / "epic.md").write_text(
|
|
7601
|
+
"---\ntype: epic\nowner: quality\n---\n\n# An epic\n")
|
|
7602
|
+
(t / "task.md").write_text("---\npriority: P0\n---\n\n# Alpha\n")
|
|
7603
|
+
keep = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
7604
|
+
os.environ["CLAUDE_CODE_SESSION_ID"] = "2222bbbb-0000-0000-0000-000000000000"
|
|
7605
|
+
try:
|
|
7606
|
+
task = model.locate(root, "alpha")
|
|
7607
|
+
assert any("read alpha against the repo" in r for r in gate.gate(root, task))
|
|
7608
|
+
check.run(root, task, "2222bbbb-0000-0000-0000-000000000000")
|
|
7609
|
+
assert not any("read alpha against the repo" in r for r in gate.gate(root, task))
|
|
7610
|
+
finally:
|
|
7611
|
+
if keep is None: os.environ.pop("CLAUDE_CODE_SESSION_ID", None)
|
|
7612
|
+
else: os.environ["CLAUDE_CODE_SESSION_ID"] = keep
|
|
7613
|
+
|
|
7614
|
+
|
|
7615
|
+
def test_a_second_session_on_the_same_item_is_asked_again():
|
|
7616
|
+
# The drift case the founder named: continuing old work in a new session is
|
|
7617
|
+
# exactly when a brief has had time to go stale and nobody has looked.
|
|
7618
|
+
import harness.model as model, harness.check as check
|
|
7619
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7620
|
+
repo = _git_repo(tmp, push=False)
|
|
7621
|
+
root = repo / "work"
|
|
7622
|
+
v = root / "versions" / "26-cut"
|
|
7623
|
+
t = v / "an-epic" / "in-progress" / "alpha"
|
|
7624
|
+
t.mkdir(parents=True)
|
|
7625
|
+
(v / "version.md").write_text(
|
|
7626
|
+
"---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
|
|
7627
|
+
(v / "an-epic" / "epic.md").write_text(
|
|
7628
|
+
"---\ntype: epic\nowner: quality\n---\n\n# An epic\n")
|
|
7629
|
+
(t / "task.md").write_text("---\npriority: P0\n---\n\n# Alpha\n")
|
|
7630
|
+
task = model.locate(root, "alpha")
|
|
7631
|
+
check.run(root, task, "first-session")
|
|
7632
|
+
assert check.checked(root, task, "first-session")
|
|
7633
|
+
assert not check.checked(root, task, "second-session")
|
|
7634
|
+
|
|
7635
|
+
|
|
7636
|
+
def test_a_caller_with_no_session_is_not_refused_over_a_record_it_could_never_write():
|
|
7637
|
+
# A plain terminal, a script, another agent. Refusing those would be refusing
|
|
7638
|
+
# everything that is not Claude Code over a marker none of them can leave.
|
|
7639
|
+
import harness.model as model, harness.check as check
|
|
7640
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7641
|
+
repo = _git_repo(tmp, push=False)
|
|
7642
|
+
root = repo / "work"
|
|
7643
|
+
v = root / "versions" / "26-cut"
|
|
7644
|
+
t = v / "an-epic" / "in-progress" / "alpha"
|
|
7645
|
+
t.mkdir(parents=True)
|
|
7646
|
+
(v / "version.md").write_text(
|
|
7647
|
+
"---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
|
|
7648
|
+
(v / "an-epic" / "epic.md").write_text(
|
|
7649
|
+
"---\ntype: epic\nowner: quality\n---\n\n# An epic\n")
|
|
7650
|
+
(t / "task.md").write_text("---\npriority: P0\n---\n\n# Alpha\n")
|
|
7651
|
+
assert check.checked(root, model.locate(root, "alpha"), "")
|
|
7652
|
+
|
|
7653
|
+
|
|
7654
|
+
def test_the_check_reads_the_epic_plan_too_not_only_the_brief():
|
|
7655
|
+
# A task deliberately does not restate the design — it lives in epic.md §Plan and
|
|
7656
|
+
# is what the task is built against, so a path that died there misleads as much.
|
|
7657
|
+
import harness.model as model, harness.check as check
|
|
7658
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7659
|
+
repo = _git_repo(tmp, push=False)
|
|
7660
|
+
root = repo / "work"
|
|
7661
|
+
v = root / "versions" / "26-cut"
|
|
7662
|
+
e = v / "an-epic"
|
|
7663
|
+
(e / "in-progress" / "alpha").mkdir(parents=True)
|
|
7664
|
+
(v / "version.md").write_text(
|
|
7665
|
+
"---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
|
|
7666
|
+
(e / "epic.md").write_text("---\ntype: epic\nowner: quality\n---\n\n"
|
|
7667
|
+
"# An epic\n\n## Plan\n\nbuilt on `src/long-gone.ts`\n")
|
|
7668
|
+
(e / "in-progress" / "alpha" / "task.md").write_text(
|
|
7669
|
+
"---\npriority: P0\n---\n\n# Alpha\n")
|
|
7670
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7671
|
+
|
|
7672
|
+
found = check.run(root, model.locate(root, "alpha"), "s1")
|
|
7673
|
+
assert [f[2] for f in found] == ["src/long-gone.ts"]
|
|
7674
|
+
|
|
7675
|
+
|
|
7676
|
+
def test_a_bare_basename_never_proposes_a_destination():
|
|
7677
|
+
# `epic.md` matches seventeen files here and `task.md` hundreds. Picking the
|
|
7678
|
+
# first of them named an unrelated epic's plan as the new home of a released
|
|
7679
|
+
# one — which a reader who trusted it would have edited their brief to point at.
|
|
7680
|
+
# Worse than silence. Reported by jarvis-14 after `check` did exactly this.
|
|
7681
|
+
from harness.drift import drift
|
|
7682
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7683
|
+
repo = _git_repo(tmp, push=False)
|
|
7684
|
+
for d in ("alpha", "beta"):
|
|
7685
|
+
(repo / "epics" / d).mkdir(parents=True, exist_ok=True)
|
|
7686
|
+
(repo / "epics" / d / "epic.md").write_text("# an epic\n")
|
|
7687
|
+
brief = repo / "work" / "brief.md"
|
|
7688
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7689
|
+
brief.write_text("the released `released-cut/epic.md`\n")
|
|
7690
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7691
|
+
|
|
7692
|
+
found = drift(repo, ["work/brief.md"])
|
|
7693
|
+
assert [(f[2], f[3], f[4]) for f in found] == [("released-cut/epic.md", "gone", "")]
|
|
7694
|
+
|
|
7695
|
+
|
|
7696
|
+
def test_several_candidates_suppress_the_suggestion_rather_than_picking_one():
|
|
7697
|
+
from harness.drift import drift
|
|
7698
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7699
|
+
repo = _git_repo(tmp, push=False)
|
|
7700
|
+
for d in ("one", "two"):
|
|
7701
|
+
(repo / d / "clients").mkdir(parents=True, exist_ok=True)
|
|
7702
|
+
(repo / d / "clients" / "reach.ts").write_text("export const a = 1;\n")
|
|
7703
|
+
brief = repo / "work" / "brief.md"
|
|
7704
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7705
|
+
brief.write_text("see `src/clients/reach.ts`\n")
|
|
7706
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7707
|
+
|
|
7708
|
+
found = drift(repo, ["work/brief.md"])
|
|
7709
|
+
assert found[0][3] == "gone" and found[0][4] == ""
|
|
7710
|
+
|
|
7711
|
+
|
|
7712
|
+
def test_one_candidate_carrying_the_named_directory_is_a_confident_move():
|
|
7713
|
+
from harness.drift import drift
|
|
7714
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7715
|
+
repo = _git_repo(tmp, push=False)
|
|
7716
|
+
(repo / "packages" / "data" / "src" / "mcp" / "tools").mkdir(parents=True)
|
|
7717
|
+
(repo / "packages" / "data" / "src" / "mcp" / "tools" / "schema.ts").write_text("x\n")
|
|
7718
|
+
brief = repo / "work" / "brief.md"
|
|
7719
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7720
|
+
brief.write_text("see `packages/data/src/tools/schema.ts`\n")
|
|
7721
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7722
|
+
|
|
7723
|
+
found = drift(repo, ["work/brief.md"])
|
|
7724
|
+
assert found[0][3] == "moved"
|
|
7725
|
+
assert found[0][4] == "packages/data/src/mcp/tools/schema.ts"
|
|
7726
|
+
|
|
7727
|
+
|
|
7728
|
+
def test_a_sentence_saying_the_file_is_not_there_is_a_claim_not_a_stale_one():
|
|
7729
|
+
# J-41's enforced_by reads "`apps/ws/src/storage.ts` does not exist" — the
|
|
7730
|
+
# absence IS the rule. Flagging it pushes somebody into deleting the most
|
|
7731
|
+
# informative half of a correct statement.
|
|
7732
|
+
from harness.drift import drift
|
|
7733
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7734
|
+
repo = _git_repo(tmp, push=False)
|
|
7735
|
+
brief = repo / "work" / "brief.md"
|
|
7736
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7737
|
+
brief.write_text("the only resolver, and `apps/ws/src/storage.ts` does not exist\n"
|
|
7738
|
+
"`packages/old/gone.ts` was deleted on purpose\n")
|
|
7739
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7740
|
+
|
|
7741
|
+
assert drift(repo, ["work/brief.md"]) == []
|
|
7742
|
+
|
|
7743
|
+
|
|
7744
|
+
def test_a_sibling_repos_path_is_not_this_checkouts_to_judge_however_it_is_spelled():
|
|
7745
|
+
# `../gotcha/x.ts` announces itself; `gotcha/x.ts` does not, and is how a brief
|
|
7746
|
+
# usually writes it. Two of a random twelve findings were a neighbour's file.
|
|
7747
|
+
from harness.drift import drift
|
|
7748
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7749
|
+
repo = _git_repo(tmp, push=False) # lands at <tmp>/repo
|
|
7750
|
+
(repo.parent / "gotcha" / ".git").mkdir(parents=True) # beside it
|
|
7751
|
+
brief = repo / "work" / "brief.md"
|
|
7752
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7753
|
+
brief.write_text("gotcha's `gotcha/apps/web/src/rate-limit.ts` and ours `src/mine.ts`\n")
|
|
7754
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7755
|
+
|
|
7756
|
+
assert [f[2] for f in drift(repo, ["work/brief.md"])] == ["src/mine.ts"]
|
|
7757
|
+
|
|
7758
|
+
|
|
7579
7759
|
if __name__ == "__main__":
|
|
7580
7760
|
tests = [v for k, v in sorted(globals().items())
|
|
7581
7761
|
if k.startswith("test_") and callable(v)]
|
package/harness/work.py
CHANGED
|
@@ -135,7 +135,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
135
135
|
|
|
136
136
|
from harness.tree import die, locate_work_root
|
|
137
137
|
from harness import events, git
|
|
138
|
-
from harness.task import cmd_code, cmd_handoff, cmd_move, cmd_new, cmd_path, cmd_place, cmd_plan, cmd_session
|
|
138
|
+
from harness.task import cmd_check, cmd_code, cmd_handoff, cmd_move, cmd_new, cmd_path, cmd_place, cmd_plan, cmd_session
|
|
139
139
|
from harness.epic import cmd_epic_new
|
|
140
140
|
from harness.version import cmd_archive, cmd_release, cmd_version_new
|
|
141
141
|
from harness.product import cmd_feature_new
|
|
@@ -167,7 +167,7 @@ SUBCOMMANDS = (
|
|
|
167
167
|
"find", "list", "readme", "move", "plan", "session", "kickoff", "path", "code",
|
|
168
168
|
"domain-new", "system-new", "where", "rules", "align", "wrap", "coverage",
|
|
169
169
|
"migrate", "next", "status", "drop", "ask", "answer", "needs", "verify",
|
|
170
|
-
"observed", "log", "digest", "sync",
|
|
170
|
+
"observed", "log", "digest", "sync", "check",
|
|
171
171
|
)
|
|
172
172
|
|
|
173
173
|
|
|
@@ -450,6 +450,11 @@ def dispatch(cmd, pos, flags, cfg) -> int:
|
|
|
450
450
|
if not pos:
|
|
451
451
|
die("usage: jarvis work handoff <name>")
|
|
452
452
|
return cmd_handoff({"name": pos[0]})
|
|
453
|
+
if cmd == "check":
|
|
454
|
+
if not pos:
|
|
455
|
+
die("usage: jarvis work check <name> "
|
|
456
|
+
"(reads the item's brief and its epic plan against the repo)")
|
|
457
|
+
return cmd_check({"name": pos[0]})
|
|
453
458
|
if cmd == "release":
|
|
454
459
|
if not pos:
|
|
455
460
|
die("usage: jarvis work release <v>")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.105",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -59,12 +59,12 @@
|
|
|
59
59
|
"@jarvis/agents": "1.0.0",
|
|
60
60
|
"@jarvis/anthropic": "1.0.0",
|
|
61
61
|
"@jarvis/board": "0.1.0",
|
|
62
|
-
"@jarvis/logger": "1.0.0",
|
|
63
|
-
"@jarvis/errors": "1.0.0",
|
|
64
62
|
"@jarvis/data": "0.1.0",
|
|
63
|
+
"@jarvis/errors": "1.0.0",
|
|
64
|
+
"@jarvis/logger": "1.0.0",
|
|
65
65
|
"@jarvis/rpc": "1.0.0",
|
|
66
|
-
"@jarvis/typescript-config": "1.0.0",
|
|
67
66
|
"@jarvis/types": "1.0.0",
|
|
67
|
+
"@jarvis/typescript-config": "1.0.0",
|
|
68
68
|
"@jarvis/ui": "0.1.0",
|
|
69
69
|
"@jarvis/vitest-config": "1.0.0"
|
|
70
70
|
},
|