@appchy/jarvis 0.1.102 → 0.1.104
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 +177 -0
- package/harness/harness/epic.py +25 -0
- package/harness/harness/gate.py +11 -0
- package/harness/harness/generate.py +6 -1
- package/harness/harness/model.py +18 -2
- package/harness/harness/report.py +5 -1
- package/harness/harness/shift.py +16 -2
- package/harness/harness/task.py +28 -0
- package/harness/test_work.py +240 -1
- package/harness/work.py +7 -2
- package/package.json +6 -6
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 = "9f128de";
|
|
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)}"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Does this brief still describe the repo?
|
|
2
|
+
|
|
3
|
+
A brief is read and believed. Nothing checks that what it names still exists, so a
|
|
4
|
+
session plans against a world that ended, and the first sign is the session hitting
|
|
5
|
+
a path that is not there. Measured on this repo, 2026-09-11:
|
|
6
|
+
|
|
7
|
+
what THIS code reports over the whole board, run against it once it was finished:
|
|
8
|
+
|
|
9
|
+
queued + in-flight briefs 35 gone 29 moved
|
|
10
|
+
live governance 20 gone 7 moved
|
|
11
|
+
shipped / archived briefs 240 gone 199 moved
|
|
12
|
+
|
|
13
|
+
The last row is fine, and is exactly why this is aimed at an ITEM rather than swept
|
|
14
|
+
over the tree: a completed brief records what was believed then and makes no claim
|
|
15
|
+
about now. The first two rows are the defect — in the exploratory pass that led
|
|
16
|
+
here, one path in six named by work a session was about to pick up pointed at a file
|
|
17
|
+
that does not exist. Across 113 session transcripts in this repo, 50 (44%) hit a
|
|
18
|
+
path that was not there and 24 (21%) said in so many words that a brief or doc was
|
|
19
|
+
stale: _"the old `unify-the-tool-surface` brief's three-way table describes a package
|
|
20
|
+
that no longer exists"_.
|
|
21
|
+
|
|
22
|
+
**Paths, and nothing else.** Two other drift classes were measured and dropped.
|
|
23
|
+
Rule ids came back 0% dead in live governance and live work — the only unresolvable
|
|
24
|
+
one was a retired `S-15` in old briefs, so an id checker would be machinery for a
|
|
25
|
+
problem this repo does not have. Work-item references came back 34% "missing" and
|
|
26
|
+
were almost entirely false: epic names whose `epic.md` release deletes by design,
|
|
27
|
+
and eslint rule names like `async-return-type` that a kebab-case regex cannot tell
|
|
28
|
+
from an item id.
|
|
29
|
+
|
|
30
|
+
**The resolver is the whole trustworthiness of this.** A first pass that checked
|
|
31
|
+
paths against the repo root alone called 37% of them missing; resolving the way a
|
|
32
|
+
writer actually means them — relative to the file, under `work/`, under `apps/cli/`
|
|
33
|
+
— took that to 16%. A check that cries wolf is the failure this epic keeps finding,
|
|
34
|
+
so a path is reported only when every honest reading of it fails.
|
|
35
|
+
|
|
36
|
+
**Moved and gone are different findings.** A file whose basename lives somewhere
|
|
37
|
+
else is a link to fix; a file with no trace anywhere is a brief describing something
|
|
38
|
+
that does not exist, and that is a rethink. Saying "missing" for both would bury the
|
|
39
|
+
second in the first, which is the more common and less important one.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import re
|
|
43
|
+
import subprocess
|
|
44
|
+
from collections import defaultdict
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
#: A path a brief NAMES, in backticks, with a real extension — `packages/x/y.ts`,
|
|
48
|
+
#: optionally with a `:42` line. Bare prose paths are out: "the work/ tree" is not a
|
|
49
|
+
#: claim about a file, and treating it as one is how a checker starts arguing with
|
|
50
|
+
#: sentences. A markdown link is out too — `links.py` already owns those, and owns
|
|
51
|
+
#: repairing them, which this never does.
|
|
52
|
+
NAMED = re.compile(r"`([A-Za-z0-9_./-]+\.(?:ts|tsx|js|mjs|cjs|py|json|md|yml|yaml|sql))(?::\d+)?`")
|
|
53
|
+
|
|
54
|
+
#: Never walked. Mirrors `links.py`'s list for the same reason: these dominate a
|
|
55
|
+
#: repo's file count and no brief names anything inside them.
|
|
56
|
+
SKIP = {"node_modules", ".git", "build", "dist", ".next", "target", "vendor",
|
|
57
|
+
".data", ".work", "coverage", ".venv", "__pycache__"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _tracked(repo: Path) -> set:
|
|
61
|
+
"""What git has. Tracked files rather than a walk: a path that exists only in
|
|
62
|
+
somebody's working tree is not something a brief can rely on, and a walk would
|
|
63
|
+
also have to re-learn every ignore rule git already knows."""
|
|
64
|
+
try:
|
|
65
|
+
out = subprocess.run(["git", "ls-files"], cwd=repo, capture_output=True,
|
|
66
|
+
text=True, timeout=30)
|
|
67
|
+
return set(out.stdout.split()) if out.returncode == 0 else set()
|
|
68
|
+
except (OSError, subprocess.SubprocessError):
|
|
69
|
+
return set()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _roots(owner: str) -> list:
|
|
73
|
+
"""Every place a writer might mean, given the file the path was written in.
|
|
74
|
+
|
|
75
|
+
A brief in `work/architecture/` writes `product/board.md` meaning its sibling;
|
|
76
|
+
one anywhere writes `harness/gate.py` meaning the tree under `apps/cli`. Both
|
|
77
|
+
read as missing against the repo root alone, and both are correct English.
|
|
78
|
+
"""
|
|
79
|
+
here = str(Path(owner).parent)
|
|
80
|
+
return ["", here, "work", "apps/cli"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve(path: str, owner: str, tracked: set) -> str:
|
|
84
|
+
for root in _roots(owner):
|
|
85
|
+
candidate = str(Path(root) / path) if root else path
|
|
86
|
+
if str(Path(candidate)) in tracked:
|
|
87
|
+
return candidate
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
#: Build output. Git does not track it, so the tracked set calls it absent — but it
|
|
92
|
+
#: exists on a machine that has built, and a brief naming `dist/hooks/session-start.js`
|
|
93
|
+
#: is right. Caught by dogfooding this on its own board, where it was one of four
|
|
94
|
+
#: findings and the only kind that was wrong.
|
|
95
|
+
BUILT = {"dist", "build", ".next", "out", "node_modules", ".data", ".work"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _unjudgeable(path: str) -> bool:
|
|
99
|
+
"""Paths this cannot have an opinion about, kept apart from paths it says are
|
|
100
|
+
fine — silence here means *not my question*, not *checked and good*.
|
|
101
|
+
|
|
102
|
+
Two kinds. Build output, which git never tracks and which is present anyway on
|
|
103
|
+
any machine that has built. And anything climbing out of the repo: a brief that
|
|
104
|
+
names `../docdoc/.claude/skills/work/SKILL.md` is talking about a sibling repo,
|
|
105
|
+
whose contents this checkout cannot see and must not pronounce on.
|
|
106
|
+
"""
|
|
107
|
+
return path.startswith("../") or bool(BUILT & set(Path(path).parts))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def named_paths(text: str):
|
|
111
|
+
"""Every path a document names, with the line it is on. Deduplicated per line so
|
|
112
|
+
one path written twice in a sentence is one finding, not two."""
|
|
113
|
+
seen = set()
|
|
114
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
115
|
+
for m in NAMED.finditer(line):
|
|
116
|
+
key = (m.group(1), n)
|
|
117
|
+
if key in seen:
|
|
118
|
+
continue
|
|
119
|
+
seen.add(key)
|
|
120
|
+
yield m.group(1), n
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def drift(repo: Path, docs, tracked=None) -> list:
|
|
124
|
+
"""What the given documents claim that the repo no longer bears out.
|
|
125
|
+
|
|
126
|
+
Each finding is `(doc, line, path, state, where)` — `state` is `"moved"` with
|
|
127
|
+
`where` naming the file that now carries that basename, or `"gone"` with no
|
|
128
|
+
`where` at all. A doc that cannot be read is skipped rather than reported: this
|
|
129
|
+
answers a question about content, and "I could not open it" is not an answer to
|
|
130
|
+
that question.
|
|
131
|
+
"""
|
|
132
|
+
tracked = _tracked(repo) if tracked is None else tracked
|
|
133
|
+
by_base = defaultdict(list)
|
|
134
|
+
for t in tracked:
|
|
135
|
+
by_base[Path(t).name].append(t)
|
|
136
|
+
|
|
137
|
+
found = []
|
|
138
|
+
for doc in docs:
|
|
139
|
+
rel = str(Path(doc).relative_to(repo)) if Path(doc).is_absolute() else str(doc)
|
|
140
|
+
try:
|
|
141
|
+
text = (repo / rel).read_text(errors="ignore")
|
|
142
|
+
except OSError:
|
|
143
|
+
continue
|
|
144
|
+
for path, line in named_paths(text):
|
|
145
|
+
if "/" not in path or _unjudgeable(path):
|
|
146
|
+
continue # no location to check, or not this repo's to answer for
|
|
147
|
+
if _resolve(path, rel, tracked):
|
|
148
|
+
continue
|
|
149
|
+
elsewhere = sorted(by_base.get(Path(path).name, []))
|
|
150
|
+
if elsewhere:
|
|
151
|
+
found.append((rel, line, path, "moved", elsewhere[0]))
|
|
152
|
+
else:
|
|
153
|
+
found.append((rel, line, path, "gone", ""))
|
|
154
|
+
return found
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def describe(findings: list) -> str:
|
|
158
|
+
"""The report a session reads. Gone first and counted separately, because a
|
|
159
|
+
brief naming something that does not exist anywhere is the finding worth acting
|
|
160
|
+
on, and a moved file is a link to fix."""
|
|
161
|
+
if not findings:
|
|
162
|
+
return " nothing named in this item has moved or gone — the brief still describes the repo."
|
|
163
|
+
|
|
164
|
+
gone = [f for f in findings if f[3] == "gone"]
|
|
165
|
+
moved = [f for f in findings if f[3] == "moved"]
|
|
166
|
+
out = []
|
|
167
|
+
if gone:
|
|
168
|
+
out.append(f" {len(gone)} path(s) named here do not exist anywhere — the brief "
|
|
169
|
+
f"describes something that is not in the repo:")
|
|
170
|
+
for doc, line, path, _, _ in gone:
|
|
171
|
+
out.append(f" {path}\n named at {doc}:{line}")
|
|
172
|
+
if moved:
|
|
173
|
+
out.append(f" {len(moved)} path(s) moved — the file is still here under another name:")
|
|
174
|
+
for doc, line, path, _, where in moved:
|
|
175
|
+
out.append(f" {path}\n now {where} ({doc}:{line})")
|
|
176
|
+
out.append(" Fix the brief as part of this work; nothing is refused over what this found.")
|
|
177
|
+
return "\n".join(out)
|
package/harness/harness/epic.py
CHANGED
|
@@ -220,6 +220,31 @@ _DURABLE_SECTIONS = ("## Governance this implies", "## Non-goals", "### Settled"
|
|
|
220
220
|
"### Forward-compat")
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
def removal_cost(version) -> tuple:
|
|
224
|
+
"""What releasing this cut would delete: `(plans, bytes, sections)`.
|
|
225
|
+
|
|
226
|
+
One derivation, because two surfaces quote this price now — the notice
|
|
227
|
+
`release` prints just before the unlink, and the line `status` shows a person
|
|
228
|
+
deciding whether to run it at all. A warning and a confirmation that disagreed
|
|
229
|
+
about the cost would make both worth less than either.
|
|
230
|
+
|
|
231
|
+
`sections` is every durable heading found across the plans, deduplicated: it is
|
|
232
|
+
what a reader needs to judge whether the text is safe to lose, and it is the
|
|
233
|
+
half that is not recoverable from a byte count.
|
|
234
|
+
"""
|
|
235
|
+
plans = [e for e in version.epics if e.planned]
|
|
236
|
+
total, held = 0, set()
|
|
237
|
+
for e in plans:
|
|
238
|
+
try:
|
|
239
|
+
text = e.md.read_text()
|
|
240
|
+
except OSError: # pragma: no cover — defensive
|
|
241
|
+
continue
|
|
242
|
+
total += len(text.encode())
|
|
243
|
+
held.update(h.split("## ")[-1].split("### ")[-1]
|
|
244
|
+
for h in _DURABLE_SECTIONS if h in text)
|
|
245
|
+
return len(plans), total, sorted(held)
|
|
246
|
+
|
|
247
|
+
|
|
223
248
|
def _cost_of_removing(version, root) -> list:
|
|
224
249
|
"""What this release is about to delete, as lines a person can act on.
|
|
225
250
|
|
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: "
|
|
@@ -35,11 +35,16 @@ def _epic_block(e, root: Path) -> str:
|
|
|
35
35
|
return f"#### Epic — {head}\n\n" + _table(tasks, root)
|
|
36
36
|
def _version_section(v: Version, root: Path) -> str:
|
|
37
37
|
status = v.status()
|
|
38
|
+
# `.get` rather than `[...]`: a state this map has not heard of should print
|
|
39
|
+
# itself in the badge, not take the whole README down. It did — adding `ready`
|
|
40
|
+
# to the derivation raised `KeyError` here and killed every board write until
|
|
41
|
+
# this line was found, which is a lot of blast radius for a label.
|
|
38
42
|
badge = {
|
|
39
43
|
"released": f"released {v.released}",
|
|
44
|
+
"ready": "ready to close · every task complete",
|
|
40
45
|
"current": "current",
|
|
41
46
|
"planned": "planned" + (f" · target {v.target}" if v.target else ""),
|
|
42
|
-
}
|
|
47
|
+
}.get(status, status)
|
|
43
48
|
link = f"[{v.title}]({_relpath_from_readme(v.md, root)})"
|
|
44
49
|
head = f"### Version — {link} _( {badge} )_"
|
|
45
50
|
if v.outcome:
|
package/harness/harness/model.py
CHANGED
|
@@ -276,10 +276,26 @@ class Version:
|
|
|
276
276
|
t.status == "complete" for t in tasks)
|
|
277
277
|
|
|
278
278
|
def status(self) -> str:
|
|
279
|
-
"""`released` if version.md carries a released date; `
|
|
280
|
-
task is in-progress;
|
|
279
|
+
"""`released` if version.md carries a released date; `ready` if every task
|
|
280
|
+
is complete and nobody has closed it; `current` if any task is in-progress;
|
|
281
|
+
otherwise `planned`.
|
|
282
|
+
|
|
283
|
+
**`ready` is the state that was missing, and its absence printed the most
|
|
284
|
+
misleading word available.** A cut with 120 tasks done and nothing open fell
|
|
285
|
+
through to `planned` — *not started yet* — so nothing on any surface said the
|
|
286
|
+
work was finished or that closing it was a person's move. It is derived from
|
|
287
|
+
the same predicate the alignment sweep and the end-of-turn line already use,
|
|
288
|
+
and it is reversible by construction: reopening one task makes `finishable`
|
|
289
|
+
false again and the cut goes straight back to `current`.
|
|
290
|
+
|
|
291
|
+
It says READY rather than *finished* deliberately. The word a person acts on
|
|
292
|
+
is the one about what is owed, and `finished` sits one column from `released`
|
|
293
|
+
on the same screen — a skim would read the cut as already out.
|
|
294
|
+
"""
|
|
281
295
|
if self.released:
|
|
282
296
|
return "released"
|
|
297
|
+
if self.finishable():
|
|
298
|
+
return "ready"
|
|
283
299
|
if any(t.status == "in-progress" for t in self.all_tasks()):
|
|
284
300
|
return "current"
|
|
285
301
|
return "planned"
|
|
@@ -76,11 +76,15 @@ def cmd_list(args) -> int:
|
|
|
76
76
|
return 0
|
|
77
77
|
|
|
78
78
|
for v in sorted(s["versions"], key=lambda x: (x.order, x.name)):
|
|
79
|
+
# Same shape, same reason as the README badge: an unknown state prints
|
|
80
|
+
# itself rather than raising out of `jarvis work list`, which is the one
|
|
81
|
+
# command everything else is read through.
|
|
79
82
|
flag = {
|
|
80
83
|
"released": f" · released {v.released}",
|
|
84
|
+
"ready": " · ready to close",
|
|
81
85
|
"current": " · current",
|
|
82
86
|
"planned": " · planned",
|
|
83
|
-
}
|
|
87
|
+
}.get(v.status(), f" · {v.status()}")
|
|
84
88
|
target = f" · target {v.target}" if v.target and not v.released else ""
|
|
85
89
|
print(f"\nVERSION {v.name} — {v.title}{flag}{target}")
|
|
86
90
|
if v.outcome:
|
package/harness/harness/shift.py
CHANGED
|
@@ -27,6 +27,7 @@ from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
|
|
|
27
27
|
from .frontmatter import rewrite_file
|
|
28
28
|
from .model import locate, missing, record_session, scan
|
|
29
29
|
from .generate import _sync
|
|
30
|
+
from .epic import removal_cost
|
|
30
31
|
from . import autonomy, events, links, peers
|
|
31
32
|
# The ceiling is read through the MODULE, never bound in with `from … import`.
|
|
32
33
|
# A `from .autonomy import CEILING` captures the value at import time, so
|
|
@@ -278,11 +279,24 @@ def cmd_status(args) -> int:
|
|
|
278
279
|
|
|
279
280
|
waiting = [(t, q) for v in s["versions"] for t in v.all_tasks()
|
|
280
281
|
for q in _open_questions(t)]
|
|
281
|
-
|
|
282
|
+
# A finished cut nobody has closed IS waiting on a person, and until now nothing
|
|
283
|
+
# said so anywhere. Closing one is deliberately not automatic — `release` deletes
|
|
284
|
+
# every epic plan and cannot be undone — so this announces and never acts, and it
|
|
285
|
+
# quotes the price so the decision is made with it visible rather than after.
|
|
286
|
+
ready = [v for v in s["versions"] if v.finishable()]
|
|
287
|
+
print(f"WAITING ON YOU ({len(waiting) + len(ready)})")
|
|
288
|
+
for v in ready:
|
|
289
|
+
plans, size, held = removal_cost(v)
|
|
290
|
+
cost = ""
|
|
291
|
+
if plans:
|
|
292
|
+
cost = (f" — releasing deletes {plans} epic plan(s), {size:,} bytes"
|
|
293
|
+
+ (f", holding §{', §'.join(held)}" if held else ""))
|
|
294
|
+
print(f" {v.name}: every task is complete and the cut is still open "
|
|
295
|
+
f"— `jarvis work release {v.name}`{cost}")
|
|
282
296
|
for t, q in waiting[:8]:
|
|
283
297
|
parts = q.split(" ")
|
|
284
298
|
print(f" {t.name}: {' '.join(parts[2:]) if len(parts) > 2 else q}")
|
|
285
|
-
if not waiting:
|
|
299
|
+
if not waiting and not ready:
|
|
286
300
|
print(" nothing — the shift is not blocked on you.")
|
|
287
301
|
|
|
288
302
|
# One read of the record, shared by every section below. It is a `git log`
|
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,
|
|
@@ -7240,6 +7246,46 @@ def test_a_cut_with_every_task_complete_says_it_is_finishable():
|
|
|
7240
7246
|
config.apply(config.DEFAULTS)
|
|
7241
7247
|
|
|
7242
7248
|
|
|
7249
|
+
def test_a_finished_cut_is_ready_rather_than_planned_and_goes_back_when_reopened():
|
|
7250
|
+
# `planned` means NOT STARTED YET, and it is what a cut with every task complete
|
|
7251
|
+
# printed — the most misleading word available for work that is finished. The
|
|
7252
|
+
# founder asked three times to close 01-one-board and the board kept calling it
|
|
7253
|
+
# unstarted. Derived and reversible in both directions, like the epic tier:
|
|
7254
|
+
# reopening one task has to take the state straight back.
|
|
7255
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7256
|
+
v = _tree(tmp)
|
|
7257
|
+
root = Path(tmp)
|
|
7258
|
+
_task(v / "complete", "one")
|
|
7259
|
+
_task(v / "complete", "two")
|
|
7260
|
+
|
|
7261
|
+
cut = model.scan(root)["versions"][0]
|
|
7262
|
+
assert cut.finishable() and cut.status() == "ready", \
|
|
7263
|
+
"every task complete and nobody closed it is its own state"
|
|
7264
|
+
|
|
7265
|
+
# Both surfaces that render a status must survive one they have not met.
|
|
7266
|
+
# Adding `ready` to the derivation took the whole README generator down
|
|
7267
|
+
# through a bare dict lookup, which is a lot of blast radius for a label.
|
|
7268
|
+
assert "ready" in generate._version_section(cut, root)
|
|
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.
|
|
7274
|
+
|
|
7275
|
+
# Reopened: straight back to current, with nothing to undo by hand.
|
|
7276
|
+
(v / "in-progress").mkdir(exist_ok=True)
|
|
7277
|
+
(v / "complete" / "two").rename(v / "in-progress" / "two")
|
|
7278
|
+
back = model.scan(root)["versions"][0]
|
|
7279
|
+
assert not back.finishable() and back.status() == "current"
|
|
7280
|
+
|
|
7281
|
+
# A released cut stays released — `ready` is about a cut nobody has closed, so
|
|
7282
|
+
# it must never shadow one that shipped.
|
|
7283
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7284
|
+
out = _tree(tmp, released="2026-09-12")
|
|
7285
|
+
_task(out / "complete", "one")
|
|
7286
|
+
assert model.scan(Path(tmp))["versions"][0].status() == "released"
|
|
7287
|
+
|
|
7288
|
+
|
|
7243
7289
|
def test_a_session_that_left_nothing_behind_is_told_nothing():
|
|
7244
7290
|
# Silence has to keep meaning clean. A line that also appears when there is
|
|
7245
7291
|
# nothing to say is one nobody can read anything out of.
|
|
@@ -7432,6 +7478,199 @@ def test_a_release_with_no_plans_left_says_nothing_about_deleting_any():
|
|
|
7432
7478
|
config.apply(config.DEFAULTS)
|
|
7433
7479
|
|
|
7434
7480
|
|
|
7481
|
+
|
|
7482
|
+
|
|
7483
|
+
# ── does this brief still describe the repo? ────────────────────────────────────
|
|
7484
|
+
# Measured before any of this was written: 50 of 113 session transcripts in this
|
|
7485
|
+
# repo hit a path that was not there, and one path in six named by work about to be
|
|
7486
|
+
# picked up pointed at a file that does not exist. The checker is only worth having
|
|
7487
|
+
# if it is quiet about the paths that are fine, so most of these are about silence.
|
|
7488
|
+
|
|
7489
|
+
def test_a_brief_naming_a_file_that_is_gone_is_reported():
|
|
7490
|
+
from harness.drift import drift
|
|
7491
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7492
|
+
repo = _git_repo(tmp, push=False)
|
|
7493
|
+
(repo / "src").mkdir(exist_ok=True)
|
|
7494
|
+
(repo / "src" / "here.ts").write_text("export const a = 1;\n")
|
|
7495
|
+
brief = repo / "work" / "brief.md"
|
|
7496
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7497
|
+
brief.write_text("names `src/here.ts` and `src/vanished.ts`\n")
|
|
7498
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7499
|
+
|
|
7500
|
+
found = drift(repo, ["work/brief.md"])
|
|
7501
|
+
assert [(f[2], f[3]) for f in found] == [("src/vanished.ts", "gone")]
|
|
7502
|
+
|
|
7503
|
+
|
|
7504
|
+
def test_a_file_that_merely_moved_is_reported_as_moved_and_says_where():
|
|
7505
|
+
# A link to fix, not a brief to rethink — burying one in the other makes the
|
|
7506
|
+
# rarer and more important finding invisible.
|
|
7507
|
+
from harness.drift import drift
|
|
7508
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7509
|
+
repo = _git_repo(tmp, push=False)
|
|
7510
|
+
(repo / "packages" / "deep").mkdir(parents=True, exist_ok=True)
|
|
7511
|
+
(repo / "packages" / "deep" / "moved.ts").write_text("export const a = 1;\n")
|
|
7512
|
+
brief = repo / "work" / "brief.md"
|
|
7513
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7514
|
+
brief.write_text("names `src/moved.ts`\n")
|
|
7515
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7516
|
+
|
|
7517
|
+
found = drift(repo, ["work/brief.md"])
|
|
7518
|
+
assert len(found) == 1
|
|
7519
|
+
assert found[0][3] == "moved"
|
|
7520
|
+
assert found[0][4] == "packages/deep/moved.ts"
|
|
7521
|
+
|
|
7522
|
+
|
|
7523
|
+
def test_a_path_written_the_way_a_writer_means_it_is_not_a_finding():
|
|
7524
|
+
# The whole trustworthiness of this. A first pass that resolved against the repo
|
|
7525
|
+
# root alone called 37% of the board's paths missing; a brief in work/ writing
|
|
7526
|
+
# `product/board.md` means its sibling, and that is correct English.
|
|
7527
|
+
from harness.drift import drift
|
|
7528
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7529
|
+
repo = _git_repo(tmp, push=False)
|
|
7530
|
+
(repo / "work" / "product").mkdir(parents=True, exist_ok=True)
|
|
7531
|
+
(repo / "work" / "product" / "board.md").write_text("# board\n")
|
|
7532
|
+
(repo / "apps" / "cli" / "harness").mkdir(parents=True, exist_ok=True)
|
|
7533
|
+
(repo / "apps" / "cli" / "harness" / "gate.py").write_text("# gate\n")
|
|
7534
|
+
brief = repo / "work" / "architecture" / "data.md"
|
|
7535
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7536
|
+
brief.write_text("see `product/board.md` and `harness/gate.py`\n")
|
|
7537
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7538
|
+
|
|
7539
|
+
assert drift(repo, ["work/architecture/data.md"]) == []
|
|
7540
|
+
|
|
7541
|
+
|
|
7542
|
+
def test_build_output_and_sibling_repos_are_not_this_checkouts_to_judge():
|
|
7543
|
+
# Both found by running this on its own board: `apps/cli/dist/hooks/session-start.js`
|
|
7544
|
+
# is real on any machine that has built and git tracks none of it, and
|
|
7545
|
+
# `../docdoc/...` is a different repo whose contents this cannot see. Silence
|
|
7546
|
+
# here means NOT MY QUESTION, which is not the same as checked-and-good.
|
|
7547
|
+
from harness.drift import drift
|
|
7548
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7549
|
+
repo = _git_repo(tmp, push=False)
|
|
7550
|
+
brief = repo / "work" / "brief.md"
|
|
7551
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7552
|
+
brief.write_text("`apps/cli/dist/hooks/session-start.js` and "
|
|
7553
|
+
"`../docdoc/.claude/skills/work/SKILL.md`\n")
|
|
7554
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7555
|
+
|
|
7556
|
+
assert drift(repo, ["work/brief.md"]) == []
|
|
7557
|
+
|
|
7558
|
+
|
|
7559
|
+
def test_prose_that_is_not_addressing_a_file_is_left_alone():
|
|
7560
|
+
# A checker that argues with sentences gets switched off. Only backticked paths
|
|
7561
|
+
# carrying a real extension AND a directory are claims about a file.
|
|
7562
|
+
from harness.drift import drift
|
|
7563
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7564
|
+
repo = _git_repo(tmp, push=False)
|
|
7565
|
+
brief = repo / "work" / "brief.md"
|
|
7566
|
+
brief.parent.mkdir(parents=True, exist_ok=True)
|
|
7567
|
+
brief.write_text("the `work/` tree, a `Session`, the word `gone.ts` alone, "
|
|
7568
|
+
"and the phrase packages/data/src/nope.ts unbackticked\n")
|
|
7569
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7570
|
+
|
|
7571
|
+
assert drift(repo, ["work/brief.md"]) == []
|
|
7572
|
+
|
|
7573
|
+
|
|
7574
|
+
def test_the_report_puts_gone_before_moved_and_refuses_nothing():
|
|
7575
|
+
from harness.drift import describe
|
|
7576
|
+
report = describe([("work/b.md", 3, "src/vanished.ts", "gone", ""),
|
|
7577
|
+
("work/b.md", 9, "src/moved.ts", "moved", "pkg/moved.ts")])
|
|
7578
|
+
assert report.index("do not exist anywhere") < report.index("moved")
|
|
7579
|
+
assert "nothing is refused" in report
|
|
7580
|
+
|
|
7581
|
+
|
|
7582
|
+
def test_a_brief_that_still_describes_the_repo_says_so_rather_than_saying_nothing():
|
|
7583
|
+
from harness.drift import describe
|
|
7584
|
+
assert "still describes the repo" in describe([])
|
|
7585
|
+
|
|
7586
|
+
|
|
7587
|
+
def test_completing_asks_whether_this_session_read_the_item_against_the_repo():
|
|
7588
|
+
# The one thing this refuses: not having looked. Never what the looking found.
|
|
7589
|
+
import harness.gate as gate, harness.model as model, harness.check as check
|
|
7590
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7591
|
+
repo = _git_repo(tmp, push=False)
|
|
7592
|
+
root = repo / "work"
|
|
7593
|
+
v = root / "versions" / "26-cut"
|
|
7594
|
+
t = v / "an-epic" / "in-progress" / "alpha"
|
|
7595
|
+
t.mkdir(parents=True)
|
|
7596
|
+
(v / "version.md").write_text(
|
|
7597
|
+
"---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
|
|
7598
|
+
(v / "an-epic" / "epic.md").write_text(
|
|
7599
|
+
"---\ntype: epic\nowner: quality\n---\n\n# An epic\n")
|
|
7600
|
+
(t / "task.md").write_text("---\npriority: P0\n---\n\n# Alpha\n")
|
|
7601
|
+
keep = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
7602
|
+
os.environ["CLAUDE_CODE_SESSION_ID"] = "2222bbbb-0000-0000-0000-000000000000"
|
|
7603
|
+
try:
|
|
7604
|
+
task = model.locate(root, "alpha")
|
|
7605
|
+
assert any("read alpha against the repo" in r for r in gate.gate(root, task))
|
|
7606
|
+
check.run(root, task, "2222bbbb-0000-0000-0000-000000000000")
|
|
7607
|
+
assert not any("read alpha against the repo" in r for r in gate.gate(root, task))
|
|
7608
|
+
finally:
|
|
7609
|
+
if keep is None: os.environ.pop("CLAUDE_CODE_SESSION_ID", None)
|
|
7610
|
+
else: os.environ["CLAUDE_CODE_SESSION_ID"] = keep
|
|
7611
|
+
|
|
7612
|
+
|
|
7613
|
+
def test_a_second_session_on_the_same_item_is_asked_again():
|
|
7614
|
+
# The drift case the founder named: continuing old work in a new session is
|
|
7615
|
+
# exactly when a brief has had time to go stale and nobody has looked.
|
|
7616
|
+
import harness.model as model, harness.check as check
|
|
7617
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7618
|
+
repo = _git_repo(tmp, push=False)
|
|
7619
|
+
root = repo / "work"
|
|
7620
|
+
v = root / "versions" / "26-cut"
|
|
7621
|
+
t = v / "an-epic" / "in-progress" / "alpha"
|
|
7622
|
+
t.mkdir(parents=True)
|
|
7623
|
+
(v / "version.md").write_text(
|
|
7624
|
+
"---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
|
|
7625
|
+
(v / "an-epic" / "epic.md").write_text(
|
|
7626
|
+
"---\ntype: epic\nowner: quality\n---\n\n# An epic\n")
|
|
7627
|
+
(t / "task.md").write_text("---\npriority: P0\n---\n\n# Alpha\n")
|
|
7628
|
+
task = model.locate(root, "alpha")
|
|
7629
|
+
check.run(root, task, "first-session")
|
|
7630
|
+
assert check.checked(root, task, "first-session")
|
|
7631
|
+
assert not check.checked(root, task, "second-session")
|
|
7632
|
+
|
|
7633
|
+
|
|
7634
|
+
def test_a_caller_with_no_session_is_not_refused_over_a_record_it_could_never_write():
|
|
7635
|
+
# A plain terminal, a script, another agent. Refusing those would be refusing
|
|
7636
|
+
# everything that is not Claude Code over a marker none of them can leave.
|
|
7637
|
+
import harness.model as model, harness.check as check
|
|
7638
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7639
|
+
repo = _git_repo(tmp, push=False)
|
|
7640
|
+
root = repo / "work"
|
|
7641
|
+
v = root / "versions" / "26-cut"
|
|
7642
|
+
t = v / "an-epic" / "in-progress" / "alpha"
|
|
7643
|
+
t.mkdir(parents=True)
|
|
7644
|
+
(v / "version.md").write_text(
|
|
7645
|
+
"---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
|
|
7646
|
+
(v / "an-epic" / "epic.md").write_text(
|
|
7647
|
+
"---\ntype: epic\nowner: quality\n---\n\n# An epic\n")
|
|
7648
|
+
(t / "task.md").write_text("---\npriority: P0\n---\n\n# Alpha\n")
|
|
7649
|
+
assert check.checked(root, model.locate(root, "alpha"), "")
|
|
7650
|
+
|
|
7651
|
+
|
|
7652
|
+
def test_the_check_reads_the_epic_plan_too_not_only_the_brief():
|
|
7653
|
+
# A task deliberately does not restate the design — it lives in epic.md §Plan and
|
|
7654
|
+
# is what the task is built against, so a path that died there misleads as much.
|
|
7655
|
+
import harness.model as model, harness.check as check
|
|
7656
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
7657
|
+
repo = _git_repo(tmp, push=False)
|
|
7658
|
+
root = repo / "work"
|
|
7659
|
+
v = root / "versions" / "26-cut"
|
|
7660
|
+
e = v / "an-epic"
|
|
7661
|
+
(e / "in-progress" / "alpha").mkdir(parents=True)
|
|
7662
|
+
(v / "version.md").write_text(
|
|
7663
|
+
"---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
|
|
7664
|
+
(e / "epic.md").write_text("---\ntype: epic\nowner: quality\n---\n\n"
|
|
7665
|
+
"# An epic\n\n## Plan\n\nbuilt on `src/long-gone.ts`\n")
|
|
7666
|
+
(e / "in-progress" / "alpha" / "task.md").write_text(
|
|
7667
|
+
"---\npriority: P0\n---\n\n# Alpha\n")
|
|
7668
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
|
|
7669
|
+
|
|
7670
|
+
found = check.run(root, model.locate(root, "alpha"), "s1")
|
|
7671
|
+
assert [f[2] for f in found] == ["src/long-gone.ts"]
|
|
7672
|
+
|
|
7673
|
+
|
|
7435
7674
|
if __name__ == "__main__":
|
|
7436
7675
|
tests = [v for k, v in sorted(globals().items())
|
|
7437
7676
|
if k.startswith("test_") and callable(v)]
|
|
@@ -7462,4 +7701,4 @@ if __name__ == "__main__":
|
|
|
7462
7701
|
fn()
|
|
7463
7702
|
print(f"ok {fn.__name__}")
|
|
7464
7703
|
shutil.rmtree(_TMP, ignore_errors=True)
|
|
7465
|
-
print(f"\n{len(tests)} passed")
|
|
7704
|
+
print(f"\n{len(tests)} passed")
|
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.104",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -57,16 +57,16 @@
|
|
|
57
57
|
"typescript": "^5.7.0",
|
|
58
58
|
"vitest": "^2.1.0",
|
|
59
59
|
"@jarvis/agents": "1.0.0",
|
|
60
|
-
"@jarvis/board": "0.1.0",
|
|
61
60
|
"@jarvis/anthropic": "1.0.0",
|
|
62
|
-
"@jarvis/
|
|
63
|
-
"@jarvis/logger": "1.0.0",
|
|
61
|
+
"@jarvis/board": "0.1.0",
|
|
64
62
|
"@jarvis/data": "0.1.0",
|
|
63
|
+
"@jarvis/errors": "1.0.0",
|
|
65
64
|
"@jarvis/rpc": "1.0.0",
|
|
66
65
|
"@jarvis/types": "1.0.0",
|
|
66
|
+
"@jarvis/logger": "1.0.0",
|
|
67
67
|
"@jarvis/typescript-config": "1.0.0",
|
|
68
|
-
"@jarvis/
|
|
69
|
-
"@jarvis/
|
|
68
|
+
"@jarvis/ui": "0.1.0",
|
|
69
|
+
"@jarvis/vitest-config": "1.0.0"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|
|
72
72
|
"dev": "tsx watch src/bin.ts start --foreground",
|