@appchy/jarvis 0.1.37 → 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.
Files changed (108) hide show
  1. package/bin/config-change.mjs +1 -1
  2. package/bin/jarvis.mjs +8 -12
  3. package/bin/pre-tool-use.mjs +2 -2
  4. package/bin/session-start.mjs +1 -1
  5. package/bin/stop.mjs +1 -1
  6. package/bin/user-prompt-submit.mjs +1 -1
  7. package/dist/bin.js +10168 -37341
  8. package/dist/bin.js.map +1 -1
  9. package/dist/data/backends.mjs +469 -0
  10. package/dist/data/chunk-7REP35VA.mjs +462 -0
  11. package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
  12. package/dist/data/chunk-AYOJSS2F.mjs +72 -0
  13. package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
  14. package/dist/data/chunk-YWSWQEJN.mjs +138 -0
  15. package/dist/data/embedders.mjs +80 -0
  16. package/dist/data/finders.mjs +35 -0
  17. package/dist/data/index.mjs +137 -0
  18. package/dist/data/linkers.mjs +83 -0
  19. package/dist/data/mcp.mjs +2624 -0
  20. package/dist/data/persistences.mjs +8 -0
  21. package/dist/data/rerankers.mjs +52 -0
  22. package/dist/data/stores.mjs +38 -0
  23. package/dist/hooks/config-change.js +7 -29
  24. package/dist/hooks/config-change.js.map +1 -1
  25. package/dist/hooks/pre-tool-use.js +129 -32
  26. package/dist/hooks/pre-tool-use.js.map +1 -1
  27. package/dist/hooks/session-start.js +72 -37
  28. package/dist/hooks/session-start.js.map +1 -1
  29. package/dist/hooks/stop.js +140 -32
  30. package/dist/hooks/stop.js.map +1 -1
  31. package/dist/hooks/user-prompt-submit.js +7 -29
  32. package/dist/hooks/user-prompt-submit.js.map +1 -1
  33. package/harness/assets/architecture-template.md +53 -0
  34. package/harness/assets/design-template.md +89 -0
  35. package/harness/assets/domains/architecture.md +43 -0
  36. package/harness/assets/domains/commercial.md +42 -0
  37. package/harness/assets/domains/design.md +42 -0
  38. package/harness/assets/domains/legal.md +43 -0
  39. package/harness/assets/domains/operations.md +39 -0
  40. package/harness/assets/domains/product.md +43 -0
  41. package/harness/assets/domains/quality.md +43 -0
  42. package/harness/assets/domains/security.md +44 -0
  43. package/harness/assets/domains/support.md +45 -0
  44. package/harness/assets/epic-template.md +85 -0
  45. package/harness/assets/feature-template.md +86 -0
  46. package/harness/assets/handoff-template.md +26 -0
  47. package/harness/assets/task-template.md +38 -0
  48. package/harness/assets/version-template.md +43 -0
  49. package/harness/harness/__init__.py +7 -0
  50. package/harness/harness/align.py +582 -0
  51. package/harness/harness/architecture.py +254 -0
  52. package/harness/harness/autonomy.py +374 -0
  53. package/harness/harness/branches.py +408 -0
  54. package/harness/harness/config.py +1482 -0
  55. package/harness/harness/coverage.py +199 -0
  56. package/harness/harness/epic.py +220 -0
  57. package/harness/harness/events.py +153 -0
  58. package/harness/harness/extend.py +99 -0
  59. package/harness/harness/frontmatter.py +218 -0
  60. package/harness/harness/gate.py +591 -0
  61. package/harness/harness/generate.py +267 -0
  62. package/harness/harness/git.py +775 -0
  63. package/harness/harness/ids.py +140 -0
  64. package/harness/harness/kickoff.py +231 -0
  65. package/harness/harness/lint.py +505 -0
  66. package/harness/harness/model.py +364 -0
  67. package/harness/harness/peers.py +187 -0
  68. package/harness/harness/product.py +29 -0
  69. package/harness/harness/registry.py +382 -0
  70. package/harness/harness/report.py +227 -0
  71. package/harness/harness/safety.py +387 -0
  72. package/harness/harness/scaffold.py +129 -0
  73. package/harness/harness/shard.py +63 -0
  74. package/harness/harness/shift.py +348 -0
  75. package/harness/harness/task.py +507 -0
  76. package/harness/harness/tree.py +258 -0
  77. package/harness/harness/version.py +305 -0
  78. package/harness/harness/wrap.py +217 -0
  79. package/harness/hooks/guard.py +259 -0
  80. package/harness/presets/appchy/PRESET.md +717 -0
  81. package/harness/presets/appchy/references/artifacts.md +539 -0
  82. package/harness/presets/appchy/references/graph.md +154 -0
  83. package/harness/presets/appchy/references/operations.md +444 -0
  84. package/harness/presets/appchy/references/research.md +216 -0
  85. package/harness/schema/work.config.schema.json +401 -0
  86. package/harness/test_work.py +5002 -0
  87. package/harness/work.py +534 -0
  88. package/package.json +34 -40
  89. package/bin/config-change.dev.mjs +0 -17
  90. package/bin/jarvis-dev.mjs +0 -30
  91. package/bin/pre-tool-use.dev.mjs +0 -23
  92. package/bin/session-start.dev.mjs +0 -17
  93. package/bin/stop.dev.mjs +0 -17
  94. package/bin/user-prompt-submit.dev.mjs +0 -17
  95. package/dev/bin.js +0 -38821
  96. package/dev/bin.js.map +0 -1
  97. package/dev/env.json +0 -1
  98. package/dev/hooks/config-change.js +0 -110
  99. package/dev/hooks/config-change.js.map +0 -1
  100. package/dev/hooks/pre-tool-use.js +0 -120
  101. package/dev/hooks/pre-tool-use.js.map +0 -1
  102. package/dev/hooks/session-start.js +0 -115
  103. package/dev/hooks/session-start.js.map +0 -1
  104. package/dev/hooks/stop.js +0 -112
  105. package/dev/hooks/stop.js.map +0 -1
  106. package/dev/hooks/user-prompt-submit.js +0 -111
  107. package/dev/hooks/user-prompt-submit.js.map +0 -1
  108. 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