@appchy/jarvis 0.1.101 → 0.1.103

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 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 = "1a10c0d";
10263
+ var SHA = "c1072c2";
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,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)
@@ -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
 
@@ -677,13 +677,22 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
677
677
  if loose:
678
678
  shown = ", ".join(loose[:5])
679
679
  more = f" and {len(loose) - 5} more" if len(loose) > 5 else ""
680
+ # WHO ELSE IS HERE, on the one refusal most likely to be about somebody
681
+ # else's file. A shared checkout is normal now, and the advice above
682
+ # ("commit them, or stash what is not this task's") is advice about
683
+ # another session's unsaved work — so a session that acts on it alone
684
+ # destroys work nobody can recover. Naming the peers turns "go and find
685
+ # out who exists" into one message. It cannot say WHICH of them touched
686
+ # a file: git does not record that and neither does anything else here.
687
+ others = peers.sharing(root.parent)
680
688
  reasons.append(
681
689
  f"{len(loose)} uncommitted change(s) outside the board — the gates "
682
690
  f"ran against this working tree, so completing now would record "
683
691
  f"shipped for code no commit contains: {shown}{more}. Commit them "
684
692
  f"(or stash or gitignore what is not this task's), then re-run "
685
693
  f"`jarvis work verify --task {task.name}` — committing moves HEAD, "
686
- f"so the evidence has to be taken on the tree that shipped.")
694
+ f"so the evidence has to be taken on the tree that shipped."
695
+ + (f" {others}" if others else ""))
687
696
  else:
688
697
  reasons.append("no `verify` commands configured — an unconfigured repo "
689
698
  "cannot prove anything, so nothing in it can complete. Set "
@@ -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
- }[status]
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:
@@ -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; `current` if any
280
- task is in-progress; otherwise `planned`."""
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"
@@ -60,9 +60,14 @@ DEFAULT_SESSIONS = "~/.claude/sessions"
60
60
  _RUNNING = {}
61
61
 
62
62
 
63
- def running():
64
- """Every agent session live on THIS machine as `{session id: name}`, or `None`
65
- when nothing here publishes that at all.
63
+ def _live():
64
+ """Every agent session live on THIS machine, keyed by session id, as the client
65
+ published it — or `None` when nothing here publishes that at all.
66
+
67
+ The whole entry rather than one field of it, because two questions are asked of
68
+ this directory now: who is running, and who is running IN THIS CHECKOUT. They
69
+ want the same scan, the same pid check and the same cache, and reading the
70
+ directory twice would let one answer be true while the other was stale.
66
71
 
67
72
  The `None` is the whole point and must not be flattened into an empty dict. No
68
73
  directory means *this client does not say*, which is where Codex and Gemini sit
@@ -119,11 +124,82 @@ def running():
119
124
  continue
120
125
  except PermissionError:
121
126
  pass # alive and owned by somebody else, which is still alive
122
- out[run] = str(entry.get("name", "")).strip()
127
+ out[run] = entry
123
128
  _RUNNING[key] = out
124
129
  return out
125
130
 
126
131
 
132
+ def running():
133
+ """Every agent session live on THIS machine as `{session id: name}`.
134
+
135
+ `None` and `{}` stay as far apart here as they are in `_live`: no directory
136
+ means *this client does not say*, and an empty mapping means something
137
+ published a list this session was not on.
138
+ """
139
+ live = _live()
140
+ if live is None:
141
+ return None
142
+ return {run: str(entry.get("name", "")).strip() for run, entry in live.items()}
143
+
144
+
145
+ def _inside(where: str, root) -> bool:
146
+ """Is `where` the same place as `root`, or somewhere under it?
147
+
148
+ Both sides are resolved because a session publishes the path it was started
149
+ with and macOS hands out two names for the same directory — `/tmp/x` and
150
+ `/private/tmp/x` — so comparing the strings reports two sessions in one
151
+ checkout as being in different ones.
152
+ """
153
+ if not where:
154
+ return False
155
+ try:
156
+ here_ = Path(where).resolve()
157
+ there = Path(root).resolve()
158
+ except (OSError, ValueError):
159
+ return False
160
+ return here_ == there or there in here_.parents
161
+
162
+
163
+ def sharing(root) -> str:
164
+ """The OTHER live sessions working inside `root`, as one line, or `""`.
165
+
166
+ Empty when nothing publishes a session list, when this session is the only one
167
+ here, or when the peers here publish no name — in each case there is nobody a
168
+ reader could go and talk to, and a line saying so is noise on a message that is
169
+ already refusing something.
170
+
171
+ **It names who is here, never who touched what.** Git cannot attribute an
172
+ uncommitted file to a session; nothing on this machine can. So the line says
173
+ which sessions share this checkout and leaves the asking to the person or the
174
+ agent, which is the honest half and the one that was missing — the cost being
175
+ paid today is not that the answer is unknowable, it is that a session refused
176
+ for somebody else's file has to go and find out who else exists before it can
177
+ even ask.
178
+ """
179
+ live = _live()
180
+ if not live:
181
+ return ""
182
+ mine = me()
183
+ names = []
184
+ for run, entry in live.items():
185
+ if run == mine or not _inside(str(entry.get("cwd", "")), root):
186
+ continue
187
+ name = str(entry.get("name", "")).strip()
188
+ # A session with no published name cannot be addressed, so naming it would
189
+ # send a reader looking for something `SendMessage` will not take.
190
+ if name:
191
+ names.append(name)
192
+ names.sort()
193
+ if not names:
194
+ return ""
195
+ shown = ", ".join(f"`{n}`" for n in names)
196
+ one = len(names) == 1
197
+ return (f"{len(names)} other session{'' if one else 's'} "
198
+ f"{'is' if one else 'are'} live in this checkout — {shown}. "
199
+ f"`SendMessage` reaches {'it' if one else 'them'}; ask before you stash "
200
+ f"or commit anything you did not write.")
201
+
202
+
127
203
  def describe(instance: str, host: str = "") -> str:
128
204
  """`instance` (and the machine it sits on) as a line that says what to do next.
129
205
 
@@ -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
- }[v.status()]
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:
@@ -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
- print(f"WAITING ON YOU ({len(waiting)})")
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`
@@ -4400,6 +4400,75 @@ def test_a_held_task_names_a_session_you_can_actually_reach():
4400
4400
  os.environ.pop(key, None)
4401
4401
 
4402
4402
 
4403
+ def test_a_refusal_about_a_shared_tree_names_who_else_is_in_it():
4404
+ # The completion gate refuses on uncommitted code and then advises stashing it
4405
+ # — which, in a checkout several sessions share, is advice about somebody
4406
+ # else's unsaved work. A session acting on it alone destroys work with no
4407
+ # reflog entry. So the refusal names the peers; finding out who exists was the
4408
+ # cost being paid, not the answer being unknowable.
4409
+ with tempfile.TemporaryDirectory() as tmp:
4410
+ try:
4411
+ here = Path(tmp) / "repo"
4412
+ (here / "src").mkdir(parents=True)
4413
+ elsewhere = Path(tmp) / "other-repo"
4414
+ elsewhere.mkdir()
4415
+ reg = Path(tmp) / "sessions"
4416
+ reg.mkdir()
4417
+ os.environ["WORK_INSTANCE"] = "mine-0000"
4418
+ os.environ["WORK_MACHINE"] = "this-box"
4419
+ os.environ["WORK_SESSIONS_DIR"] = str(reg)
4420
+ peers._RUNNING.clear()
4421
+
4422
+ def publish(n, run, name, cwd, pid=None):
4423
+ (reg / f"{n}.json").write_text(json.dumps(
4424
+ {"sessionId": run, "name": name, "cwd": str(cwd),
4425
+ "pid": os.getpid() if pid is None else pid}))
4426
+
4427
+ # Me, so I am never named to myself; a peer deeper inside the same
4428
+ # checkout, because a session started in a subdirectory shares the tree
4429
+ # exactly as much; a session in a different repo; a dead one; and one
4430
+ # live here that publishes no name, which cannot be addressed.
4431
+ publish(1, "mine-0000", "repo-me", here / "src")
4432
+ publish(2, "peer-1111", "repo-a8", here / "src")
4433
+ publish(3, "away-2222", "repo-zz", elsewhere)
4434
+ publish(4, "ghost-3333", "repo-b7", here, pid=2 ** 22)
4435
+ publish(5, "mute-4444", "", here)
4436
+
4437
+ line = peers.sharing(here)
4438
+ assert "repo-a8" in line, "a live peer in this checkout has to be named"
4439
+ assert "1 other session is" in line, \
4440
+ "me, another repo, a ghost and an unaddressable one are all not peers here"
4441
+ for absent in ("repo-me", "repo-zz", "repo-b7"):
4442
+ assert absent not in line, f"{absent} is not somebody to ask"
4443
+
4444
+ # A second live peer makes it plural, and they are named in a stable
4445
+ # order — a message that reshuffles reads as new information.
4446
+ publish(6, "peer-5555", "repo-c9", here)
4447
+ peers._RUNNING.clear()
4448
+ both = peers.sharing(here)
4449
+ assert "2 other sessions are" in both and both.index("repo-a8") < both.index("repo-c9")
4450
+
4451
+ # A checkout nobody else is in is SILENCE, not a line saying so: this
4452
+ # rides on a message that is already refusing something.
4453
+ empty = Path(tmp) / "nobody-here"
4454
+ empty.mkdir()
4455
+ peers._RUNNING.clear()
4456
+ assert peers.sharing(empty) == ""
4457
+ # …and `elsewhere` is not silent, because a session really is in it.
4458
+ # The scoping is by checkout, not by "anywhere but mine".
4459
+ assert "repo-zz" in peers.sharing(elsewhere)
4460
+
4461
+ # And a client that publishes nothing at all must not read as an empty
4462
+ # room — it is the same `None` that stops every peer looking dead.
4463
+ os.environ["WORK_SESSIONS_DIR"] = str(Path(tmp) / "nothing-here")
4464
+ peers._RUNNING.clear()
4465
+ assert peers.running() is None and peers.sharing(here) == ""
4466
+ finally:
4467
+ for key in ("WORK_INSTANCE", "WORK_MACHINE", "WORK_SESSIONS_DIR"):
4468
+ os.environ.pop(key, None)
4469
+ peers._RUNNING.clear()
4470
+
4471
+
4403
4472
  def test_how_long_ago_is_said_in_the_unit_that_changes_the_decision():
4404
4473
  # The question this answers is *is anybody on this*, and the reader is deciding
4405
4474
  # whether to take the work. "6h ago" settles that; a timestamp makes them do
@@ -7171,6 +7240,44 @@ def test_a_cut_with_every_task_complete_says_it_is_finishable():
7171
7240
  config.apply(config.DEFAULTS)
7172
7241
 
7173
7242
 
7243
+ def test_a_finished_cut_is_ready_rather_than_planned_and_goes_back_when_reopened():
7244
+ # `planned` means NOT STARTED YET, and it is what a cut with every task complete
7245
+ # printed — the most misleading word available for work that is finished. The
7246
+ # founder asked three times to close 01-one-board and the board kept calling it
7247
+ # unstarted. Derived and reversible in both directions, like the epic tier:
7248
+ # reopening one task has to take the state straight back.
7249
+ with tempfile.TemporaryDirectory() as tmp:
7250
+ v = _tree(tmp)
7251
+ root = Path(tmp)
7252
+ _task(v / "complete", "one")
7253
+ _task(v / "complete", "two")
7254
+
7255
+ cut = model.scan(root)["versions"][0]
7256
+ assert cut.finishable() and cut.status() == "ready", \
7257
+ "every task complete and nobody closed it is its own state"
7258
+
7259
+ # Both surfaces that render a status must survive one they have not met.
7260
+ # Adding `ready` to the derivation took the whole README generator down
7261
+ # through a bare dict lookup, which is a lot of blast radius for a label.
7262
+ assert "ready" in generate._version_section(cut, root)
7263
+ # `report` renders the same status through its own map; both now fall back
7264
+ # to printing an unknown state rather than raising.
7265
+ assert report is not None
7266
+
7267
+ # Reopened: straight back to current, with nothing to undo by hand.
7268
+ (v / "in-progress").mkdir(exist_ok=True)
7269
+ (v / "complete" / "two").rename(v / "in-progress" / "two")
7270
+ back = model.scan(root)["versions"][0]
7271
+ assert not back.finishable() and back.status() == "current"
7272
+
7273
+ # A released cut stays released — `ready` is about a cut nobody has closed, so
7274
+ # it must never shadow one that shipped.
7275
+ with tempfile.TemporaryDirectory() as tmp:
7276
+ out = _tree(tmp, released="2026-09-12")
7277
+ _task(out / "complete", "one")
7278
+ assert model.scan(Path(tmp))["versions"][0].status() == "released"
7279
+
7280
+
7174
7281
  def test_a_session_that_left_nothing_behind_is_told_nothing():
7175
7282
  # Silence has to keep meaning clean. A line that also appears when there is
7176
7283
  # nothing to say is one nobody can read anything out of.
@@ -7363,6 +7470,112 @@ def test_a_release_with_no_plans_left_says_nothing_about_deleting_any():
7363
7470
  config.apply(config.DEFAULTS)
7364
7471
 
7365
7472
 
7473
+
7474
+
7475
+ # ── does this brief still describe the repo? ────────────────────────────────────
7476
+ # Measured before any of this was written: 50 of 113 session transcripts in this
7477
+ # repo hit a path that was not there, and one path in six named by work about to be
7478
+ # picked up pointed at a file that does not exist. The checker is only worth having
7479
+ # if it is quiet about the paths that are fine, so most of these are about silence.
7480
+
7481
+ def test_a_brief_naming_a_file_that_is_gone_is_reported():
7482
+ from harness.drift import drift
7483
+ with tempfile.TemporaryDirectory() as tmp:
7484
+ repo = _git_repo(tmp, push=False)
7485
+ (repo / "src").mkdir(exist_ok=True)
7486
+ (repo / "src" / "here.ts").write_text("export const a = 1;\n")
7487
+ brief = repo / "work" / "brief.md"
7488
+ brief.parent.mkdir(parents=True, exist_ok=True)
7489
+ brief.write_text("names `src/here.ts` and `src/vanished.ts`\n")
7490
+ _git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
7491
+
7492
+ found = drift(repo, ["work/brief.md"])
7493
+ assert [(f[2], f[3]) for f in found] == [("src/vanished.ts", "gone")]
7494
+
7495
+
7496
+ def test_a_file_that_merely_moved_is_reported_as_moved_and_says_where():
7497
+ # A link to fix, not a brief to rethink — burying one in the other makes the
7498
+ # rarer and more important finding invisible.
7499
+ from harness.drift import drift
7500
+ with tempfile.TemporaryDirectory() as tmp:
7501
+ repo = _git_repo(tmp, push=False)
7502
+ (repo / "packages" / "deep").mkdir(parents=True, exist_ok=True)
7503
+ (repo / "packages" / "deep" / "moved.ts").write_text("export const a = 1;\n")
7504
+ brief = repo / "work" / "brief.md"
7505
+ brief.parent.mkdir(parents=True, exist_ok=True)
7506
+ brief.write_text("names `src/moved.ts`\n")
7507
+ _git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
7508
+
7509
+ found = drift(repo, ["work/brief.md"])
7510
+ assert len(found) == 1
7511
+ assert found[0][3] == "moved"
7512
+ assert found[0][4] == "packages/deep/moved.ts"
7513
+
7514
+
7515
+ def test_a_path_written_the_way_a_writer_means_it_is_not_a_finding():
7516
+ # The whole trustworthiness of this. A first pass that resolved against the repo
7517
+ # root alone called 37% of the board's paths missing; a brief in work/ writing
7518
+ # `product/board.md` means its sibling, and that is correct English.
7519
+ from harness.drift import drift
7520
+ with tempfile.TemporaryDirectory() as tmp:
7521
+ repo = _git_repo(tmp, push=False)
7522
+ (repo / "work" / "product").mkdir(parents=True, exist_ok=True)
7523
+ (repo / "work" / "product" / "board.md").write_text("# board\n")
7524
+ (repo / "apps" / "cli" / "harness").mkdir(parents=True, exist_ok=True)
7525
+ (repo / "apps" / "cli" / "harness" / "gate.py").write_text("# gate\n")
7526
+ brief = repo / "work" / "architecture" / "data.md"
7527
+ brief.parent.mkdir(parents=True, exist_ok=True)
7528
+ brief.write_text("see `product/board.md` and `harness/gate.py`\n")
7529
+ _git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
7530
+
7531
+ assert drift(repo, ["work/architecture/data.md"]) == []
7532
+
7533
+
7534
+ def test_build_output_and_sibling_repos_are_not_this_checkouts_to_judge():
7535
+ # Both found by running this on its own board: `apps/cli/dist/hooks/session-start.js`
7536
+ # is real on any machine that has built and git tracks none of it, and
7537
+ # `../docdoc/...` is a different repo whose contents this cannot see. Silence
7538
+ # here means NOT MY QUESTION, which is not the same as checked-and-good.
7539
+ from harness.drift import drift
7540
+ with tempfile.TemporaryDirectory() as tmp:
7541
+ repo = _git_repo(tmp, push=False)
7542
+ brief = repo / "work" / "brief.md"
7543
+ brief.parent.mkdir(parents=True, exist_ok=True)
7544
+ brief.write_text("`apps/cli/dist/hooks/session-start.js` and "
7545
+ "`../docdoc/.claude/skills/work/SKILL.md`\n")
7546
+ _git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
7547
+
7548
+ assert drift(repo, ["work/brief.md"]) == []
7549
+
7550
+
7551
+ def test_prose_that_is_not_addressing_a_file_is_left_alone():
7552
+ # A checker that argues with sentences gets switched off. Only backticked paths
7553
+ # carrying a real extension AND a directory are claims about a file.
7554
+ from harness.drift import drift
7555
+ with tempfile.TemporaryDirectory() as tmp:
7556
+ repo = _git_repo(tmp, push=False)
7557
+ brief = repo / "work" / "brief.md"
7558
+ brief.parent.mkdir(parents=True, exist_ok=True)
7559
+ brief.write_text("the `work/` tree, a `Session`, the word `gone.ts` alone, "
7560
+ "and the phrase packages/data/src/nope.ts unbackticked\n")
7561
+ _git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
7562
+
7563
+ assert drift(repo, ["work/brief.md"]) == []
7564
+
7565
+
7566
+ def test_the_report_puts_gone_before_moved_and_refuses_nothing():
7567
+ from harness.drift import describe
7568
+ report = describe([("work/b.md", 3, "src/vanished.ts", "gone", ""),
7569
+ ("work/b.md", 9, "src/moved.ts", "moved", "pkg/moved.ts")])
7570
+ assert report.index("do not exist anywhere") < report.index("moved")
7571
+ assert "nothing is refused" in report
7572
+
7573
+
7574
+ def test_a_brief_that_still_describes_the_repo_says_so_rather_than_saying_nothing():
7575
+ from harness.drift import describe
7576
+ assert "still describes the repo" in describe([])
7577
+
7578
+
7366
7579
  if __name__ == "__main__":
7367
7580
  tests = [v for k, v in sorted(globals().items())
7368
7581
  if k.startswith("test_") and callable(v)]
@@ -7393,4 +7606,4 @@ if __name__ == "__main__":
7393
7606
  fn()
7394
7607
  print(f"ok {fn.__name__}")
7395
7608
  shutil.rmtree(_TMP, ignore_errors=True)
7396
- print(f"\n{len(tests)} passed")
7609
+ print(f"\n{len(tests)} passed")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.101",
3
+ "version": "0.1.103",
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/data": "0.1.0",
61
60
  "@jarvis/anthropic": "1.0.0",
62
61
  "@jarvis/board": "0.1.0",
63
- "@jarvis/errors": "1.0.0",
64
62
  "@jarvis/logger": "1.0.0",
63
+ "@jarvis/errors": "1.0.0",
64
+ "@jarvis/data": "0.1.0",
65
65
  "@jarvis/rpc": "1.0.0",
66
+ "@jarvis/typescript-config": "1.0.0",
66
67
  "@jarvis/types": "1.0.0",
67
68
  "@jarvis/ui": "0.1.0",
68
- "@jarvis/vitest-config": "1.0.0",
69
- "@jarvis/typescript-config": "1.0.0"
69
+ "@jarvis/vitest-config": "1.0.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",