@appchy/jarvis 0.1.36 → 0.1.39
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/bin/config-change.mjs +1 -1
- package/bin/jarvis.mjs +8 -12
- package/bin/pre-tool-use.mjs +2 -2
- package/bin/session-start.mjs +1 -1
- package/bin/stop.mjs +1 -1
- package/bin/user-prompt-submit.mjs +1 -1
- package/dist/bin.js +10104 -37174
- package/dist/bin.js.map +1 -1
- package/dist/data/backends.mjs +469 -0
- package/dist/data/chunk-7REP35VA.mjs +462 -0
- package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
- package/dist/data/chunk-AYOJSS2F.mjs +72 -0
- package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
- package/dist/data/chunk-YWSWQEJN.mjs +138 -0
- package/dist/data/embedders.mjs +80 -0
- package/dist/data/finders.mjs +35 -0
- package/dist/data/index.mjs +137 -0
- package/dist/data/linkers.mjs +83 -0
- package/dist/data/mcp.mjs +2624 -0
- package/dist/data/persistences.mjs +8 -0
- package/dist/data/rerankers.mjs +52 -0
- package/dist/data/stores.mjs +38 -0
- package/dist/hooks/config-change.js +7 -29
- package/dist/hooks/config-change.js.map +1 -1
- package/dist/hooks/pre-tool-use.js +129 -32
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js +72 -37
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js +140 -32
- package/dist/hooks/stop.js.map +1 -1
- package/dist/hooks/user-prompt-submit.js +7 -29
- package/dist/hooks/user-prompt-submit.js.map +1 -1
- package/harness/assets/architecture-template.md +53 -0
- package/harness/assets/design-template.md +89 -0
- package/harness/assets/domains/architecture.md +43 -0
- package/harness/assets/domains/commercial.md +42 -0
- package/harness/assets/domains/design.md +42 -0
- package/harness/assets/domains/legal.md +43 -0
- package/harness/assets/domains/operations.md +39 -0
- package/harness/assets/domains/product.md +43 -0
- package/harness/assets/domains/quality.md +43 -0
- package/harness/assets/domains/security.md +44 -0
- package/harness/assets/domains/support.md +45 -0
- package/harness/assets/epic-template.md +85 -0
- package/harness/assets/feature-template.md +86 -0
- package/harness/assets/handoff-template.md +26 -0
- package/harness/assets/task-template.md +38 -0
- package/harness/assets/version-template.md +43 -0
- package/harness/harness/__init__.py +7 -0
- package/harness/harness/align.py +582 -0
- package/harness/harness/architecture.py +254 -0
- package/harness/harness/autonomy.py +374 -0
- package/harness/harness/branches.py +408 -0
- package/harness/harness/config.py +1482 -0
- package/harness/harness/coverage.py +199 -0
- package/harness/harness/epic.py +220 -0
- package/harness/harness/events.py +153 -0
- package/harness/harness/extend.py +99 -0
- package/harness/harness/frontmatter.py +218 -0
- package/harness/harness/gate.py +591 -0
- package/harness/harness/generate.py +267 -0
- package/harness/harness/git.py +775 -0
- package/harness/harness/ids.py +140 -0
- package/harness/harness/kickoff.py +231 -0
- package/harness/harness/lint.py +505 -0
- package/harness/harness/model.py +364 -0
- package/harness/harness/peers.py +187 -0
- package/harness/harness/product.py +29 -0
- package/harness/harness/registry.py +382 -0
- package/harness/harness/report.py +227 -0
- package/harness/harness/safety.py +387 -0
- package/harness/harness/scaffold.py +129 -0
- package/harness/harness/shard.py +63 -0
- package/harness/harness/shift.py +348 -0
- package/harness/harness/task.py +507 -0
- package/harness/harness/tree.py +258 -0
- package/harness/harness/version.py +305 -0
- package/harness/harness/wrap.py +217 -0
- package/harness/hooks/guard.py +259 -0
- package/harness/presets/appchy/PRESET.md +717 -0
- package/harness/presets/appchy/references/artifacts.md +539 -0
- package/harness/presets/appchy/references/graph.md +154 -0
- package/harness/presets/appchy/references/operations.md +444 -0
- package/harness/presets/appchy/references/research.md +216 -0
- package/harness/schema/work.config.schema.json +401 -0
- package/harness/test_work.py +5002 -0
- package/harness/work.py +534 -0
- package/package.json +34 -40
- package/bin/config-change.dev.mjs +0 -17
- package/bin/jarvis-dev.mjs +0 -30
- package/bin/pre-tool-use.dev.mjs +0 -23
- package/bin/session-start.dev.mjs +0 -17
- package/bin/stop.dev.mjs +0 -17
- package/bin/user-prompt-submit.dev.mjs +0 -17
- package/dev/bin.js +0 -38718
- package/dev/bin.js.map +0 -1
- package/dev/env.json +0 -1
- package/dev/hooks/config-change.js +0 -110
- package/dev/hooks/config-change.js.map +0 -1
- package/dev/hooks/pre-tool-use.js +0 -120
- package/dev/hooks/pre-tool-use.js.map +0 -1
- package/dev/hooks/session-start.js +0 -115
- package/dev/hooks/session-start.js.map +0 -1
- package/dev/hooks/stop.js +0 -112
- package/dev/hooks/stop.js.map +0 -1
- package/dev/hooks/user-prompt-submit.js +0 -111
- package/dev/hooks/user-prompt-submit.js.map +0 -1
- package/dist/env.json +0 -1
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""The board on other branches — read-only, and answered from this clone.
|
|
2
|
+
|
|
3
|
+
An agent blocked on something cannot tell whether the work it needs is finished
|
|
4
|
+
somewhere else, not started, or being done right now by somebody else. The answer
|
|
5
|
+
is already on disk: every machine has the whole repo, so another branch's board is
|
|
6
|
+
`git show <ref>:work/…` and costs no network at all.
|
|
7
|
+
|
|
8
|
+
**Reading is all this does.** A take, an update or a completion happens on the
|
|
9
|
+
branch you have checked out and nowhere else — asking to act elsewhere is refused
|
|
10
|
+
saying so, rather than quietly writing to a ref you are not on. Reading any branch
|
|
11
|
+
and writing your own is the whole of the arrangement: a branch's board describes
|
|
12
|
+
that branch's world and merges when the branch does.
|
|
13
|
+
|
|
14
|
+
Three things are deliberately NOT here. The map refuses for another branch — it is
|
|
15
|
+
a built artifact per checkout, and answering would mean building one, which is a
|
|
16
|
+
manual step even for the branch you are on; a half-answer joining one branch's
|
|
17
|
+
items to another branch's code would quietly mislead. Nothing warns that somebody
|
|
18
|
+
else's PR touches your files: that needs a definition of "overlaps" and is its own
|
|
19
|
+
piece of work. And nothing here talks to another agent — seeing why you are blocked
|
|
20
|
+
is this; being told is the notices feature.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from harness import git
|
|
30
|
+
from harness.tree import BUCKETS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#: How long a PR lookup is believed. A sweep over twenty items must not become
|
|
34
|
+
#: twenty API calls, and a PR's state does not change on a timescale that makes a
|
|
35
|
+
#: few minutes wrong. `--refresh` is the answer when it does.
|
|
36
|
+
PR_TTL = 300
|
|
37
|
+
_PR_CACHE = "work-prs.json"
|
|
38
|
+
|
|
39
|
+
#: `versions/<version>/<epic>/<bucket>/<name>/task.md`, or the backlog's shorter
|
|
40
|
+
#: form. The bucket IS the status, so a path answers where an item stands without
|
|
41
|
+
#: reading a byte of it — which is what makes reading a whole branch cheap.
|
|
42
|
+
_ITEM = re.compile(r"^work/(versions|backlog)/.+/task\.md$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def items_at(repo, ref: str) -> list:
|
|
46
|
+
"""Every item on `ref`, from that ref's tree alone.
|
|
47
|
+
|
|
48
|
+
No checkout, no stash, no touching the working tree: `ls-tree` reads the object
|
|
49
|
+
store, so this is safe to run with uncommitted changes sitting in front of you.
|
|
50
|
+
"""
|
|
51
|
+
code, out, _ = git._git(repo, "ls-tree", "-r", "--name-only", ref, "--", "work/")
|
|
52
|
+
if code != 0:
|
|
53
|
+
return []
|
|
54
|
+
found = []
|
|
55
|
+
for path in out.splitlines():
|
|
56
|
+
path = path.strip()
|
|
57
|
+
if not _ITEM.match(path):
|
|
58
|
+
continue
|
|
59
|
+
parts = path.split("/")
|
|
60
|
+
backlog = parts[1] == "backlog"
|
|
61
|
+
bucket = "queue" if backlog else (parts[-3] if len(parts) >= 3 else "")
|
|
62
|
+
found.append({
|
|
63
|
+
"name": parts[-2],
|
|
64
|
+
"status": bucket if bucket in BUCKETS else "queue",
|
|
65
|
+
"epic": parts[2] if backlog else (parts[3] if len(parts) > 3 else ""),
|
|
66
|
+
"version": "backlog" if backlog else parts[2],
|
|
67
|
+
"path": path,
|
|
68
|
+
})
|
|
69
|
+
return sorted(found, key=lambda i: i["name"])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def brief_at(repo, ref: str, path: str) -> str:
|
|
73
|
+
"""One brief's text as `ref` has it. Empty when that ref does not carry it."""
|
|
74
|
+
code, out, _ = git._git(repo, "show", f"{ref}:{path}")
|
|
75
|
+
return out if code == 0 else ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def branches_of(repo, name: str) -> list:
|
|
79
|
+
"""Every branch carrying work on `name`, newest commit first.
|
|
80
|
+
|
|
81
|
+
Read from the COMMITS rather than from the trees, because a branch that has the
|
|
82
|
+
file and has never touched it is not where the work is happening. The trailers
|
|
83
|
+
slice 1 started writing are what make this answerable at all.
|
|
84
|
+
"""
|
|
85
|
+
code, out, _ = git._git(
|
|
86
|
+
repo, "log", "--all", "--format=%H%x1f%aI%x1f%an", "-E",
|
|
87
|
+
f"--grep=^{git.ITEM}: {re.escape(name)}$", timeout=60)
|
|
88
|
+
if code != 0 or not out.strip():
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
seen = {}
|
|
92
|
+
for line in out.splitlines():
|
|
93
|
+
parts = line.split("\x1f")
|
|
94
|
+
if len(parts) < 3:
|
|
95
|
+
continue
|
|
96
|
+
sha, when, who = parts[0], parts[1], parts[2]
|
|
97
|
+
for branch in _containing(repo, sha):
|
|
98
|
+
prior = seen.get(branch)
|
|
99
|
+
if not prior or when > prior["when"]:
|
|
100
|
+
seen[branch] = {"branch": branch, "when": when, "by": who, "sha": sha[:12]}
|
|
101
|
+
return sorted(seen.values(), key=lambda b: b["when"], reverse=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _containing(repo, sha: str) -> list:
|
|
105
|
+
"""Branches holding a commit, remote-tracking refs folded onto what they track.
|
|
106
|
+
|
|
107
|
+
`origin/x` and `x` are one branch that happens to exist in two places, and
|
|
108
|
+
listing both reads as two people working it — the exact confusion this command
|
|
109
|
+
is here to clear up. Folded by REMOTE NAME rather than by splitting on the
|
|
110
|
+
first slash, because `claude/fix-something` is a branch with a slash in it and
|
|
111
|
+
is nobody's remote.
|
|
112
|
+
"""
|
|
113
|
+
code, out, _ = git._git(repo, "branch", "--all", "--format=%(refname:short)",
|
|
114
|
+
"--contains", sha)
|
|
115
|
+
if code != 0:
|
|
116
|
+
return []
|
|
117
|
+
remotes = [r for r in git._git(repo, "remote")[1].split() if r]
|
|
118
|
+
names = []
|
|
119
|
+
for line in out.splitlines():
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if not line or "HEAD ->" in line:
|
|
122
|
+
continue
|
|
123
|
+
if line.startswith("remotes/"):
|
|
124
|
+
line = line[len("remotes/"):]
|
|
125
|
+
for remote in remotes:
|
|
126
|
+
if line.startswith(f"{remote}/"):
|
|
127
|
+
line = line[len(remote) + 1:]
|
|
128
|
+
break
|
|
129
|
+
if line and line not in names:
|
|
130
|
+
names.append(line)
|
|
131
|
+
return names
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def current(repo) -> str:
|
|
135
|
+
"""The branch this clone has checked out, or "" on a detached HEAD."""
|
|
136
|
+
return git._git(repo, "branch", "--show-current")[1].strip()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def refs_state(repo) -> dict:
|
|
140
|
+
"""Which refs this clone can answer from, and how old they are.
|
|
141
|
+
|
|
142
|
+
Offline is a normal state, not a failure — the whole point is that another
|
|
143
|
+
branch's board is already here. What is NOT honest is presenting a ref fetched
|
|
144
|
+
a week ago as current, so this reports when the origin was last actually
|
|
145
|
+
REACHED, which `git.py` records as an outcome rather than reading a timestamp
|
|
146
|
+
that a failed fetch also updates.
|
|
147
|
+
"""
|
|
148
|
+
_, _, synced = git._last_probe(repo)
|
|
149
|
+
# A SPACE, not `%x1f`: `for-each-ref` does not expand hex escapes the way
|
|
150
|
+
# `log --format` does — it emits the literal text `%x1f`, so every ref came
|
|
151
|
+
# back with the separator glued into its name and matched nothing. A space is
|
|
152
|
+
# safe here because git forbids one in a ref name, and the strict date carries
|
|
153
|
+
# none either.
|
|
154
|
+
code, out, _ = git._git(repo, "for-each-ref",
|
|
155
|
+
"--format=%(refname:short) %(committerdate:iso8601-strict)",
|
|
156
|
+
"refs/heads", "refs/remotes")
|
|
157
|
+
refs = []
|
|
158
|
+
if code == 0:
|
|
159
|
+
for line in out.splitlines():
|
|
160
|
+
name, _, when = line.strip().partition(" ")
|
|
161
|
+
if name and "HEAD" not in name:
|
|
162
|
+
refs.append({"ref": name, "when": when.strip()})
|
|
163
|
+
refs.sort(key=lambda r: r["when"], reverse=True)
|
|
164
|
+
return {"refs": refs, "synced": synced, "current": current(repo)}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# =============================================================================
|
|
168
|
+
# Pull requests
|
|
169
|
+
# =============================================================================
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def pr_for(repo, branch: str, refresh: bool = False) -> dict:
|
|
173
|
+
"""The PR a branch is being reviewed in, or {} when there is none to find.
|
|
174
|
+
|
|
175
|
+
**Cached, because a sweep over twenty items would otherwise be twenty API
|
|
176
|
+
calls.** This is the only real network call in the whole arrangement;
|
|
177
|
+
everything else above is local git against objects already on disk.
|
|
178
|
+
|
|
179
|
+
Uses `gh`, which is how a developer machine already talks to GitHub, and says
|
|
180
|
+
nothing rather than guessing when it is absent or logged out. An absent `gh` is
|
|
181
|
+
the same answer as an unreachable origin: this clone cannot tell you, which is
|
|
182
|
+
a true statement, unlike "there is no PR".
|
|
183
|
+
"""
|
|
184
|
+
cache = _pr_cache(repo)
|
|
185
|
+
hit = cache.get(branch)
|
|
186
|
+
if hit and not refresh and (time.time() - hit.get("at", 0)) < PR_TTL:
|
|
187
|
+
return hit.get("pr")
|
|
188
|
+
|
|
189
|
+
pr = _ask_gh(repo, branch)
|
|
190
|
+
# A failure is not cached. "I could not ask" is a fact about this moment, and
|
|
191
|
+
# remembering it for five minutes would turn one flaky call into five minutes
|
|
192
|
+
# of pretending not to know.
|
|
193
|
+
if pr is not None:
|
|
194
|
+
cache[branch] = {"at": time.time(), "pr": pr}
|
|
195
|
+
_write_pr_cache(repo, cache)
|
|
196
|
+
return pr
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _ask_gh(repo, branch: str):
|
|
200
|
+
"""`{}` when the branch has no PR, `None` when this clone could not ask.
|
|
201
|
+
|
|
202
|
+
Two different answers that a single empty value would flatten into one: "there
|
|
203
|
+
is no PR" is a fact about the branch, and "gh is not installed here" is a fact
|
|
204
|
+
about this machine. Saying the first when you mean the second is the same lie a
|
|
205
|
+
stale board tells.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
p = subprocess.run(
|
|
209
|
+
["gh", "pr", "list", "--head", branch, "--state", "all", "--limit", "1",
|
|
210
|
+
"--json", "number,title,state,url,isDraft"],
|
|
211
|
+
cwd=str(repo), capture_output=True, text=True, timeout=15)
|
|
212
|
+
except (OSError, subprocess.SubprocessError):
|
|
213
|
+
return None
|
|
214
|
+
if p.returncode != 0:
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
rows = json.loads(p.stdout or "[]")
|
|
218
|
+
except ValueError:
|
|
219
|
+
return None
|
|
220
|
+
if not rows:
|
|
221
|
+
return {}
|
|
222
|
+
row = rows[0]
|
|
223
|
+
return {"number": row.get("number"), "title": row.get("title", ""),
|
|
224
|
+
"state": str(row.get("state", "")).lower(),
|
|
225
|
+
"draft": bool(row.get("isDraft")), "url": row.get("url", "")}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def branch_of_pr(repo, number: str) -> str:
|
|
229
|
+
"""The branch a PR is on, so a PR resolves back to the items being done on it."""
|
|
230
|
+
try:
|
|
231
|
+
p = subprocess.run(["gh", "pr", "view", str(number), "--json", "headRefName"],
|
|
232
|
+
cwd=str(repo), capture_output=True, text=True, timeout=15)
|
|
233
|
+
except (OSError, subprocess.SubprocessError):
|
|
234
|
+
return ""
|
|
235
|
+
if p.returncode != 0:
|
|
236
|
+
return ""
|
|
237
|
+
try:
|
|
238
|
+
return str(json.loads(p.stdout or "{}").get("headRefName") or "")
|
|
239
|
+
except ValueError:
|
|
240
|
+
return ""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _pr_cache_path(repo):
|
|
244
|
+
d = git._git(repo, "rev-parse", "--git-dir")[1].strip()
|
|
245
|
+
return (Path(repo) / d / _PR_CACHE) if d else None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _pr_cache(repo) -> dict:
|
|
249
|
+
p = _pr_cache_path(repo)
|
|
250
|
+
try:
|
|
251
|
+
return json.loads(p.read_text())
|
|
252
|
+
except (OSError, ValueError, AttributeError):
|
|
253
|
+
return {}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _write_pr_cache(repo, cache: dict) -> None:
|
|
257
|
+
p = _pr_cache_path(repo)
|
|
258
|
+
try:
|
|
259
|
+
p.write_text(json.dumps(cache))
|
|
260
|
+
except (OSError, AttributeError, TypeError):
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# =============================================================================
|
|
265
|
+
# Commands
|
|
266
|
+
# =============================================================================
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _repo():
|
|
270
|
+
from .tree import die, locate_work_root
|
|
271
|
+
root = locate_work_root()[0]
|
|
272
|
+
if root is None:
|
|
273
|
+
die("no work/ tree found — there is no board here to read")
|
|
274
|
+
repo = root.parent
|
|
275
|
+
if not git.is_repo(repo):
|
|
276
|
+
die(f"{repo} is not a git repository — a board on another branch needs one")
|
|
277
|
+
return repo
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cmd_find(args) -> int:
|
|
281
|
+
"""Where a piece of work is being done, in either direction.
|
|
282
|
+
|
|
283
|
+
An item resolves to the branches carrying it and the PR each is in; a branch or
|
|
284
|
+
a PR resolves back to the items on it. One command for both because it is one
|
|
285
|
+
question — *where is this happening* — and splitting it by which end you happen
|
|
286
|
+
to be holding would mean remembering two.
|
|
287
|
+
"""
|
|
288
|
+
repo = _repo()
|
|
289
|
+
target = str(args["target"]).strip()
|
|
290
|
+
refresh = bool(args.get("refresh"))
|
|
291
|
+
state = refs_state(repo)
|
|
292
|
+
|
|
293
|
+
# A PR number first: `#123` says so outright, and a bare number cannot be an
|
|
294
|
+
# item name, which the harness requires to be kebab-case.
|
|
295
|
+
number = target[1:] if target.startswith("#") else target
|
|
296
|
+
if number.isdigit():
|
|
297
|
+
branch = branch_of_pr(repo, number)
|
|
298
|
+
if not branch:
|
|
299
|
+
print(f" #{number} cannot tell — `gh` is not answering here")
|
|
300
|
+
_say_refs(state)
|
|
301
|
+
return 0
|
|
302
|
+
print(f" #{number} branch {branch}")
|
|
303
|
+
_say_items(repo, branch)
|
|
304
|
+
_say_refs(state)
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
known = {r["ref"] for r in state["refs"]}
|
|
308
|
+
if target in known:
|
|
309
|
+
print(f" {target} branch")
|
|
310
|
+
pr = pr_for(repo, target, refresh)
|
|
311
|
+
print(f" pr {_pr_line(pr)}")
|
|
312
|
+
_say_items(repo, target)
|
|
313
|
+
_say_refs(state)
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
found = branches_of(repo, target)
|
|
317
|
+
if not found:
|
|
318
|
+
# Not knowing WHERE is different from not knowing OF, and the two want
|
|
319
|
+
# different next steps from whoever hit this.
|
|
320
|
+
# One tree read per ref, not two: this walks every ref the clone has, and
|
|
321
|
+
# a board of a hundred items across forty refs is slow enough already
|
|
322
|
+
# without asking each one the same question twice.
|
|
323
|
+
anywhere = [b["ref"] for b in state["refs"]
|
|
324
|
+
if any(i["name"] == target for i in items_at(repo, b["ref"]))]
|
|
325
|
+
if anywhere:
|
|
326
|
+
print(f" {target} on {', '.join(anywhere[:5])}, "
|
|
327
|
+
f"but no commit here has touched it")
|
|
328
|
+
else:
|
|
329
|
+
print(f" {target} nothing on any ref this clone has names it")
|
|
330
|
+
_say_refs(state)
|
|
331
|
+
return 0
|
|
332
|
+
|
|
333
|
+
print(f" {target}")
|
|
334
|
+
here = current(repo)
|
|
335
|
+
for entry in found:
|
|
336
|
+
mine = " ← you are here" if entry["branch"] == here else ""
|
|
337
|
+
print(f" branch {entry['branch']}{mine}")
|
|
338
|
+
print(f" last touched {entry['when'][:10]} by {entry['by']} ({entry['sha']})")
|
|
339
|
+
print(f" pr {_pr_line(pr_for(repo, entry['branch'], refresh))}")
|
|
340
|
+
_say_refs(state)
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _pr_line(pr) -> str:
|
|
345
|
+
if pr is None:
|
|
346
|
+
return "cannot tell — `gh` is not answering on this machine"
|
|
347
|
+
if not pr:
|
|
348
|
+
return "no pull request for this branch"
|
|
349
|
+
draft = " · draft" if pr.get("draft") else ""
|
|
350
|
+
return f"#{pr.get('number')} {pr.get('state')}{draft} — {pr.get('title', '')}"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _say_items(repo, ref: str) -> None:
|
|
354
|
+
items = items_at(repo, ref)
|
|
355
|
+
if not items:
|
|
356
|
+
print(" items none on that ref")
|
|
357
|
+
return
|
|
358
|
+
moving = [i for i in items if i["status"] != "complete"]
|
|
359
|
+
print(f" items {len(items)} on that ref, {len(moving)} still moving")
|
|
360
|
+
for item in moving[:12]:
|
|
361
|
+
print(f" {item['status']:<12} {item['name']}")
|
|
362
|
+
if len(moving) > 12:
|
|
363
|
+
print(f" … and {len(moving) - 12} more")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _say_refs(state: dict) -> None:
|
|
367
|
+
"""What answered, and how old it is. An offline clone gives a real answer from
|
|
368
|
+
the refs it has; what it must never do is pass them off as current."""
|
|
369
|
+
synced = state.get("synced") or "never"
|
|
370
|
+
print(f" refs {len(state['refs'])} on this clone · origin last reached {synced}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def cmd_at(args) -> int:
|
|
374
|
+
"""The board as another branch has it.
|
|
375
|
+
|
|
376
|
+
Printed on its own rather than merged into the current branch's, because two
|
|
377
|
+
branches are ALLOWED to disagree — a branch's board describes that branch's
|
|
378
|
+
world. Merging them would hide exactly the difference somebody ran this to see.
|
|
379
|
+
"""
|
|
380
|
+
repo = _repo()
|
|
381
|
+
ref = str(args["ref"]).strip()
|
|
382
|
+
state = refs_state(repo)
|
|
383
|
+
known = {r["ref"] for r in state["refs"]}
|
|
384
|
+
if ref not in known:
|
|
385
|
+
from .tree import die
|
|
386
|
+
die(f"this clone has no ref called '{ref}'. It answers from what it already "
|
|
387
|
+
f"has — {len(state['refs'])} ref(s), origin last reached "
|
|
388
|
+
f"{state.get('synced') or 'never'}. `git fetch` first if you expect a "
|
|
389
|
+
f"newer one, or `jarvis work find` to see where work is happening.")
|
|
390
|
+
|
|
391
|
+
items = items_at(repo, ref)
|
|
392
|
+
if not items:
|
|
393
|
+
print(f" {ref} no work/ tree on that ref")
|
|
394
|
+
_say_refs(state)
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
here = " (this is the branch you are on)" if ref == state.get("current") else ""
|
|
398
|
+
print(f" BOARD as {ref} has it{here} — {len(items)} item(s), read-only")
|
|
399
|
+
for bucket in BUCKETS:
|
|
400
|
+
rows = [i for i in items if i["status"] == bucket]
|
|
401
|
+
if not rows:
|
|
402
|
+
continue
|
|
403
|
+
print(f"\n {bucket.upper()} ({len(rows)})")
|
|
404
|
+
for item in rows:
|
|
405
|
+
print(f" {item['name']:<44} {item['epic']}")
|
|
406
|
+
print()
|
|
407
|
+
_say_refs(state)
|
|
408
|
+
return 0
|