@appchy/jarvis 0.1.69 → 0.1.70

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
@@ -10115,7 +10115,7 @@ import { createRequire as createRequire2 } from "module";
10115
10115
  var _require = createRequire2(import.meta.url);
10116
10116
  var VERSION2 = _require("../package.json").version ?? "0.0.0";
10117
10117
  var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
10118
- var SHA = "f0e0cf3";
10118
+ var SHA = "8636478";
10119
10119
  var BUILT = "2026-09-10";
10120
10120
  var BUILD = SHA ?? "source";
10121
10121
  var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
@@ -131,7 +131,7 @@ def cmd_init(args) -> int:
131
131
 
132
132
  write(root / "README.md", _README)
133
133
  write(root / "ROADMAP.md", _ROADMAP)
134
- for d in ("versions", "backlog"):
134
+ for d in ("versions", "versions/backlog", "versions/archive"):
135
135
  (root / d).mkdir(parents=True, exist_ok=True)
136
136
  # Reading order, not alphabetical — and read from config, so a repo that added a
137
137
  # tenth domain gets it scaffolded too rather than having to remember to.
@@ -39,7 +39,7 @@ _PR_CACHE = "work-prs.json"
39
39
  #: `versions/<version>/<epic>/<bucket>/<name>/task.md`, or the backlog's shorter
40
40
  #: form. The bucket IS the status, so a path answers where an item stands without
41
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$")
42
+ _ITEM = re.compile(r"^work/versions/.+/task\.md$")
43
43
 
44
44
 
45
45
  def items_at(repo, ref: str) -> list:
@@ -57,12 +57,12 @@ def items_at(repo, ref: str) -> list:
57
57
  if not _ITEM.match(path):
58
58
  continue
59
59
  parts = path.split("/")
60
- backlog = parts[1] == "backlog"
60
+ backlog = len(parts) > 2 and parts[2] == "backlog"
61
61
  bucket = "queue" if backlog else (parts[-3] if len(parts) >= 3 else "")
62
62
  found.append({
63
63
  "name": parts[-2],
64
64
  "status": bucket if bucket in BUCKETS else "queue",
65
- "epic": parts[2] if backlog else (parts[3] if len(parts) > 3 else ""),
65
+ "epic": parts[3] if len(parts) > 3 else "",
66
66
  "version": "backlog" if backlog else parts[2],
67
67
  "path": path,
68
68
  })
@@ -1,7 +1,7 @@
1
1
  import shutil
2
2
  from datetime import date
3
3
 
4
- from .tree import BUCKETS, EPIC_FM_ORDER, die, find_work_root, rel
4
+ from .tree import backlog_dir, BUCKETS, EPIC_FM_ORDER, die, find_work_root, rel
5
5
  from .frontmatter import rewrite_file
6
6
  from .model import epic_home, locate_epic, locate_version
7
7
  from .scaffold import _check_covers_ref, _check_kebab, _check_unused, _scaffold_epic
@@ -53,7 +53,7 @@ def cmd_epic_new(args) -> int:
53
53
  folder = version.folder / name
54
54
  home = f"version '{version_name}'"
55
55
  else:
56
- folder = root / "backlog" / name
56
+ folder = backlog_dir(root) / name
57
57
  home = "backlog"
58
58
 
59
59
  if folder.exists():
@@ -92,7 +92,7 @@ def _epic_to_backlog(root, name: str) -> int:
92
92
  f"Move its unfinished tasks out one at a time instead:\n"
93
93
  f" jarvis work place <task> --backlog --epic <a backlog epic>")
94
94
 
95
- dest = root / "backlog" / name
95
+ dest = backlog_dir(root) / name
96
96
  if dest.exists():
97
97
  die(f"{rel(dest, root)} already exists")
98
98
  was = epic_home(root, epic)
@@ -0,0 +1,140 @@
1
+ """Keep relative links pointing at what they pointed at, across a move.
2
+
3
+ **A link is repaired by IDENTITY, never by text.** What a link resolved to before
4
+ the move is what it must resolve to after it. Everything else falls out of that:
5
+ a referrer that got deeper needs more `../`, a target that moved needs a new path,
6
+ and both are the same question asked once.
7
+
8
+ It is also the only way to catch the class nothing else can. After a renumber
9
+ hands an old name to a different folder, a stale pointer still RESOLVES — to a
10
+ real file, the wrong one — and reads as correct. A checker arriving afterwards
11
+ sees a link that works and has no memory that the name used to mean something
12
+ else. Only the thing performing the move holds that map, so the check lives here
13
+ or nowhere. Measured on one repo: seven such pointers survived a renumber, green
14
+ on every link check it had.
15
+
16
+ Deliberately NOT driven by a doc checker's broken-link output. That repairs only
17
+ what is already broken, which is exactly the set that excludes the case above.
18
+ """
19
+
20
+ import os
21
+ import re
22
+ from pathlib import Path
23
+
24
+ #: Markdown inline links. Reference-style links and bare paths in prose are out of
25
+ #: scope on purpose: rewriting text that is not addressing a file is how a repair
26
+ #: tool starts editing sentences.
27
+ LINK = re.compile(r"\]\(([^)\s]+)\)")
28
+
29
+ #: Directories no board link ever points into, and which dominate a repo's file
30
+ #: count. Skipping them is what keeps this ~1s rather than ~1min.
31
+ SKIP = {"node_modules", ".git", "build", "dist", ".next", "target", "vendor",
32
+ ".data", "coverage", ".venv", "__pycache__"}
33
+
34
+
35
+ def _docs(repo: Path):
36
+ for p in repo.rglob("*.md"):
37
+ if SKIP & set(p.parts):
38
+ continue
39
+ # Resolved, so every path in play is in one namespace. On macOS `/var` is a
40
+ # symlink to `/private/var`, and mixing the two makes a sibling look like a
41
+ # cousin seven levels up — which is what a relative path would then say.
42
+ yield p.resolve()
43
+
44
+
45
+ def snapshot(repo: Path) -> dict:
46
+ """Every relative markdown link in the repo, and the file it resolves to NOW.
47
+
48
+ Links that already resolve to nothing are recorded as such and never repaired:
49
+ a link broken before the move is somebody else's bug, and inventing a target
50
+ for it would be a guess wearing a repair's clothes.
51
+ """
52
+ seen = {}
53
+ for doc in _docs(repo):
54
+ try:
55
+ text = doc.read_text(errors="ignore")
56
+ except OSError:
57
+ continue
58
+ found = []
59
+ for m in LINK.finditer(text):
60
+ href = m.group(1)
61
+ if "://" in href or href.startswith(("#", "mailto:", "/")):
62
+ continue
63
+ target = (doc.parent / href.split("#")[0]).resolve()
64
+ found.append((href, target, target.exists()))
65
+ if found:
66
+ seen[doc] = found
67
+ return seen
68
+
69
+
70
+ def _after(path: Path, moves: dict) -> Path:
71
+ """Where `path` is now, given what moved. Children follow their parent."""
72
+ for old, new in moves.items():
73
+ if path == old:
74
+ return new
75
+ try:
76
+ return new / path.relative_to(old)
77
+ except ValueError:
78
+ continue
79
+ return path
80
+
81
+
82
+ def repair(before: dict, moves: dict) -> tuple:
83
+ """Rewrite links so they resolve to what they resolved to before.
84
+
85
+ Returns (repaired, unresolved) — the count fixed, and a line per link this
86
+ could not place, which is reported rather than guessed at.
87
+ """
88
+ repaired, unresolved = 0, []
89
+
90
+ for doc, links in before.items():
91
+ now = _after(doc, moves)
92
+ if not now.is_file():
93
+ unresolved.append(f"{doc} — the file itself is gone")
94
+ continue
95
+
96
+ text = original = now.read_text(errors="ignore")
97
+ for href, target, existed in links:
98
+ if not existed:
99
+ continue
100
+ want = _after(target, moves)
101
+ if want == target and now == doc:
102
+ continue # neither end moved
103
+
104
+ path, _, anchor = href.partition("#")
105
+ fixed = os.path.relpath(want, now.parent) + (f"#{anchor}" if anchor else "")
106
+ if fixed == href:
107
+ continue
108
+ # Anchored on the link's own syntax, so a path that also appears in
109
+ # prose is not rewritten by accident.
110
+ if f"]({href})" not in text:
111
+ unresolved.append(f"{now} — {href}")
112
+ continue
113
+ text = text.replace(f"]({href})", f"]({fixed})")
114
+ repaired += 1
115
+
116
+ if text != original:
117
+ now.write_text(text)
118
+
119
+ return repaired, unresolved
120
+
121
+
122
+ def stale_mentions(repo: Path, old_name: str, moved_into: Path) -> list:
123
+ """Prose still naming a folder that has moved — the part no link check sees.
124
+
125
+ A rename does not only break links. `08` meant one cut before a renumber and
126
+ another after it, and every sentence naming it kept reading as correct. These
127
+ are reported, never edited: whether a mention is stale or a deliberate record
128
+ of what was true then is a question only a person can answer.
129
+ """
130
+ hits = []
131
+ for doc in _docs(repo):
132
+ if moved_into in doc.parents:
133
+ continue
134
+ try:
135
+ for n, line in enumerate(doc.read_text(errors="ignore").splitlines(), 1):
136
+ if old_name in line and f"]({old_name}" not in line:
137
+ hits.append(f"{doc.relative_to(repo)}:{n}")
138
+ except OSError:
139
+ continue
140
+ return hits
@@ -3,7 +3,7 @@ import re
3
3
  from datetime import date
4
4
  from pathlib import Path
5
5
 
6
- from .tree import BUCKETS, DONE_TIER, PRIORITIES, RESERVED_MD
6
+ from .tree import backlog_dir, BUCKETS, DONE_TIER, PRIORITIES, RESERVED_MD
7
7
  from .frontmatter import as_list, parse_frontmatter, read_item, rewrite_file, title_of
8
8
 
9
9
 
@@ -279,7 +279,7 @@ def scan(root: Path) -> dict:
279
279
  versions.sort(key=lambda v: (v.order, v.name))
280
280
 
281
281
  backlog, backlog_epics = [], []
282
- bdir = root / "backlog"
282
+ bdir = backlog_dir(root)
283
283
  if bdir.is_dir():
284
284
  # A folder is an EPIC if it holds epic.md, a TASK if it holds task.md.
285
285
  # The marker file is the discriminator, so there is no registry to keep
@@ -10,9 +10,20 @@ def _check_kebab(kind: str, name: str):
10
10
  if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", name):
11
11
  die(f"{kind} name must be kebab-case (got '{name}')")
12
12
  def _check_version_name(name: str):
13
- """Version names allow dots and dashes (semver-style): v0.3, v1.0, 0.2.1-rc1."""
14
- if not re.match(r"^[a-z0-9]+([._-][a-z0-9]+)*$", name):
15
- die(f"version name must be kebab-case or dotted (got '{name}')")
13
+ """A version folder is `NN-<name>`, and the number is part of the NAME.
14
+
15
+ `order:` in the frontmatter is not enough and never was: it is invisible in a
16
+ directory listing, in a path, and in every link a brief writes. So a cut created
17
+ with `--order 0` and a bare name landed unprefixed beside `01-…`, and once the
18
+ tool cannot produce the shape it wants, people scaffold folders by hand — which
19
+ is how one repo ended up with five cuts all reading as current and an `order:`
20
+ that had stopped meaning anything.
21
+ """
22
+ if not re.match(r"^\d{2,}-[a-z0-9]+(-[a-z0-9]+)*$", name):
23
+ die(f"version name must be NN-<name> — two or more digits, a dash, then "
24
+ f"kebab-case (got '{name}'). The number orders the cut and is part of "
25
+ f"the folder name, because that is where it is visible: in a listing, "
26
+ f"in a path, and in every link that points at it.")
16
27
  def _check_priority(p: str):
17
28
  if p not in PRIORITIES:
18
29
  die(f"priority must be one of {', '.join(PRIORITIES)}")
@@ -7,6 +7,7 @@ from . import peers, tree
7
7
  # `TASK_TAGS_OK` is deliberately NOT imported by name: `config.apply` binds it on
8
8
  # the `tree` module, and a `from … import` captures the value at import time, so
9
9
  # the name here would still hold the pre-config default. Read through the module.
10
+ from . import links
10
11
  from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
11
12
  from .frontmatter import as_list, parse_frontmatter, read_item, rewrite_file, split_frontmatter
12
13
  from .model import current_session_id, locate, locate_epic, locate_version, record_session, scan
@@ -185,7 +186,21 @@ def cmd_place(args) -> int:
185
186
  if dest.exists():
186
187
  die(f"{rel(dest, root)} already exists")
187
188
  dest.parent.mkdir(parents=True, exist_ok=True)
189
+
190
+ # What every link in the repo pointed at, read BEFORE the move — because after
191
+ # it there is nothing left to compare against, and a link that still resolves
192
+ # to the wrong file is indistinguishable from one that is right.
193
+ repo = root.parent
194
+ before = links.snapshot(repo)
195
+
188
196
  shutil.move(str(task.folder), str(dest))
197
+
198
+ repaired, unresolved = links.repair(before, {task.folder.resolve(): dest.resolve()})
199
+ if repaired:
200
+ print(f" repaired {repaired} link(s) that pointed at it")
201
+ for line in unresolved:
202
+ print(f" COULD NOT PLACE {line}")
203
+
189
204
  md = dest / "task.md"
190
205
  rewrite_file(
191
206
  md,
@@ -203,10 +203,23 @@ def locate_work_root() -> tuple:
203
203
  tried.append(str(cand))
204
204
  for d in (cur, *cur.parents):
205
205
  cand = d / "work"
206
- if cand.is_dir() and any((cand / sub).is_dir()
207
- for sub in ("versions", "backlog")):
206
+ if cand.is_dir() and (cand / "versions").is_dir():
208
207
  return cand, tried, cur
209
208
  return None, tried, cur
209
+
210
+
211
+ def backlog_dir(root: Path) -> Path:
212
+ """Where work waits before it is cut. Inside `versions/` because it is one of
213
+ the states a cut's work is in, not a place beside the cuts — the tree now reads
214
+ versions/{backlog,archive,complete,NN-name}."""
215
+ return root / "versions" / "backlog"
216
+
217
+
218
+ def archive_dir(root: Path) -> Path:
219
+ """Where a finished cut goes when releasing it would be a lie. A cut whose work
220
+ all moved elsewhere has nothing left to deliver and no outcome it met; without
221
+ this it sits in the live list looking open forever."""
222
+ return root / "versions" / "archive"
210
223
  def assets_dir() -> Path:
211
224
  return PAYLOAD_DIR / "assets"
212
225
  def rel(p: Path, root: Path) -> str:
@@ -401,7 +401,7 @@ def _tree(tmp: str, name: str = "26-cut", outcome: str = "a user can do x",
401
401
  root = Path(tmp)
402
402
  v = root / "versions" / name
403
403
  v.mkdir(parents=True)
404
- (root / "backlog").mkdir(exist_ok=True)
404
+ (root / "versions" / "backlog").mkdir(exist_ok=True)
405
405
  # Every mutating command ends in `_sync`, which regenerates this file and
406
406
  # dies without its markers — so a fixture that omits it fails the command
407
407
  # for the wrong reason.
@@ -562,7 +562,7 @@ def test_a_backlog_epic_holds_its_tasks_without_buckets():
562
562
  with tempfile.TemporaryDirectory() as tmp:
563
563
  root = Path(tmp)
564
564
  _tree(tmp)
565
- e = _epic(root / "backlog", "richer-exercise-vocabulary")
565
+ e = _epic(root / "versions" / "backlog", "richer-exercise-vocabulary")
566
566
  _task(e, "code-block")
567
567
  s = model.scan(root)
568
568
  assert [x.name for x in s["backlog_epics"]] == ["richer-exercise-vocabulary"]
@@ -576,8 +576,8 @@ def test_a_loose_backlog_task_still_resolves_alongside_epics():
576
576
  with tempfile.TemporaryDirectory() as tmp:
577
577
  root = Path(tmp)
578
578
  _tree(tmp)
579
- _epic(root / "backlog", "an-epic")
580
- _task(root / "backlog", "a-loose-one")
579
+ _epic(root / "versions" / "backlog", "an-epic")
580
+ _task(root / "versions" / "backlog", "a-loose-one")
581
581
  s = model.scan(root)
582
582
  assert [t.name for t in s["backlog"]] == ["a-loose-one"]
583
583
  assert s["backlog"][0].epic is None
@@ -618,12 +618,12 @@ def test_work_comes_back_out_of_a_cut_it_does_not_belong_in():
618
618
  e = _epic(v, "an-epic")
619
619
  (e / "queue").mkdir()
620
620
  _task(e / "queue", "not-this-cut")
621
- _epic(root / "backlog", "later-on")
621
+ _epic(root / "versions" / "backlog", "later-on")
622
622
  os.environ["WORK_DIR"] = tmp
623
623
  try:
624
624
  task.cmd_place({"name": "not-this-cut", "backlog": "true",
625
625
  "epic": "later-on"})
626
- assert (root / "backlog" / "later-on" / "not-this-cut").is_dir()
626
+ assert (root / "versions" / "backlog" / "later-on" / "not-this-cut").is_dir()
627
627
  # A backlog task has no bucket — status only exists inside a cut.
628
628
  assert not (e / "queue" / "not-this-cut").exists()
629
629
  finally:
@@ -641,7 +641,7 @@ def test_what_a_release_delivered_cannot_be_edited_afterwards():
641
641
  e = _epic(v, "an-epic")
642
642
  (e / "complete").mkdir()
643
643
  _task(e / "complete", "already-shipped")
644
- _epic(root / "backlog", "later-on")
644
+ _epic(root / "versions" / "backlog", "later-on")
645
645
  os.environ["WORK_DIR"] = tmp
646
646
  try:
647
647
  for move in ({"backlog": "true", "epic": "later-on"},
@@ -664,13 +664,13 @@ def test_an_epic_carried_into_a_later_cut_says_what_it_continues():
664
664
  import os
665
665
  _tree(tmp)
666
666
  root = Path(tmp)
667
- _epic(root / "backlog", "the-original")
667
+ _epic(root / "versions" / "backlog", "the-original")
668
668
  os.environ["WORK_DIR"] = tmp
669
669
  try:
670
670
  epic.cmd_epic_new({"name": "the-rest-of-it",
671
671
  "continues": "the-original"})
672
672
  fm = frontmatter.parse_frontmatter(
673
- (root / "backlog" / "the-rest-of-it" / "epic.md").read_text())
673
+ (root / "versions" / "backlog" / "the-rest-of-it" / "epic.md").read_text())
674
674
  assert fm.get("continues") == "the-original"
675
675
  assert model.scan(root)["backlog_epics"], "and it is on the board"
676
676
  # A link to nothing is worse than no link: it reads as provenance and
@@ -831,7 +831,7 @@ def test_a_backlog_epic_is_not_asked_what_it_covers():
831
831
  with tempfile.TemporaryDirectory() as tmp:
832
832
  root = Path(tmp)
833
833
  _tree(tmp)
834
- _epic(root / "backlog", "you-reach-it-from-your-phone", covers="[]")
834
+ _epic(root / "versions" / "backlog", "you-reach-it-from-your-phone", covers="[]")
835
835
  assert not any("you-reach-it-from-your-phone: no covers:"
836
836
  in w for w in _warns(root))
837
837
 
@@ -842,7 +842,7 @@ def test_a_backlog_epic_is_not_held_to_the_task_floor():
842
842
  with tempfile.TemporaryDirectory() as tmp:
843
843
  root = Path(tmp)
844
844
  _tree(tmp)
845
- _epic(root / "backlog", "it-sings-what-you-write", covers="[]")
845
+ _epic(root / "versions" / "backlog", "it-sings-what-you-write", covers="[]")
846
846
  assert not any("it-sings-what-you-write: 0 task(s)"
847
847
  in w for w in _warns(root))
848
848
 
@@ -1778,6 +1778,42 @@ def test_doctor_passes_a_degraded_but_legal_install_and_fails_a_real_break():
1778
1778
  assert f"{tree.cli()} init" in out.getvalue()
1779
1779
 
1780
1780
 
1781
+ def test_links_are_repaired_by_what_they_pointed_at_not_by_what_they_say():
1782
+ from harness import links
1783
+ with tempfile.TemporaryDirectory() as tmp:
1784
+ repo = Path(tmp)
1785
+ (repo / "a" / "deep").mkdir(parents=True)
1786
+ (repo / "b").mkdir()
1787
+ (repo / "target.md").write_text("# target\n")
1788
+ (repo / "b" / "other.md").write_text("# other\n")
1789
+
1790
+ # Three links from one file: one at a thing that will move, one at a thing
1791
+ # that will not, and one that is already dead.
1792
+ doc = repo / "a" / "note.md"
1793
+ doc.write_text(
1794
+ "See [it](../target.md), and [other](../b/other.md), "
1795
+ "and [gone](../nowhere.md).\n")
1796
+
1797
+ before = links.snapshot(repo)
1798
+ moved_to = repo / "a" / "deep" / "target.md"
1799
+ (repo / "target.md").rename(moved_to)
1800
+ repaired, unresolved = links.repair(
1801
+ before, {(repo / "target.md").resolve(): moved_to.resolve()})
1802
+
1803
+ text = doc.read_text()
1804
+ assert "](deep/target.md)" in text, f"target moved, link did not follow: {text}"
1805
+ assert "](../b/other.md)" in text, "a link at something that did not move was touched"
1806
+ assert "](../nowhere.md)" in text, "a link dead before the move was invented a target"
1807
+ assert repaired == 1 and unresolved == []
1808
+
1809
+ # The class nothing else catches: the old name now belongs to a DIFFERENT
1810
+ # real file, so the stale pointer still resolves and reads as correct.
1811
+ (repo / "target.md").write_text("# an impostor now wearing the old name\n")
1812
+ assert (repo / "a" / "../target.md").exists()
1813
+ assert "](deep/target.md)" in doc.read_text(), \
1814
+ "identity, not resolvability, is what the repair preserves"
1815
+
1816
+
1781
1817
  def test_place_carries_a_tasks_bucket_into_the_new_cut():
1782
1818
  # The bucket IS the status, so landing everything in `queue/` does not move a
1783
1819
  # task, it un-starts it — silently, and for blocked work the obvious repair is
@@ -1945,7 +1981,9 @@ def test_init_scaffolds_the_whole_tree_and_is_idempotent_and_non_destructive():
1945
1981
  assert (root / "README.md").is_file() and (root / "ROADMAP.md").is_file()
1946
1982
  assert tree.BACKLOG_START in (root / "README.md").read_text(), \
1947
1983
  "`_regen_readme` dies without the markers, so `init` must write them"
1948
- for d in ("versions", "backlog"):
1984
+ # backlog and archive are STATES a cut's work is in, so they live inside
1985
+ # versions/ beside the cuts rather than as siblings of it.
1986
+ for d in ("versions", "versions/backlog", "versions/archive"):
1949
1987
  assert (root / d).is_dir()
1950
1988
  assert len(registry.scan_domains(root)) == 9
1951
1989
 
@@ -3497,8 +3535,8 @@ def test_a_gitignored_board_is_the_failure_wearing_a_success_s_clothes():
3497
3535
  _git(repo, "rm", "-r", "-q", "--cached", "work")
3498
3536
  _git(repo, "add", ".gitignore")
3499
3537
  _git(repo, "commit", "-qm", "ignore the board")
3500
- (repo / "work" / "backlog").mkdir(parents=True, exist_ok=True)
3501
- (repo / "work" / "backlog" / "an-item.md").write_text("a board write\n")
3538
+ (repo / "work" / "versions" / "backlog").mkdir(parents=True, exist_ok=True)
3539
+ (repo / "work" / "versions" / "backlog" / "an-item.md").write_text("a board write\n")
3502
3540
 
3503
3541
  assert git.ignored(repo) == ["work"], \
3504
3542
  "an ignored board must be nameable, or nothing can report it"
@@ -3525,8 +3563,8 @@ def test_sync_recovers_a_branch_that_never_had_an_upstream():
3525
3563
  _git(repo, "checkout", "-qb", "never-pushed")
3526
3564
  assert _git(repo, "rev-parse", "--abbrev-ref", "@{upstream}").returncode != 0, \
3527
3565
  "the whole point of this test is a branch with no upstream"
3528
- (repo / "work" / "backlog").mkdir(parents=True, exist_ok=True)
3529
- (repo / "work" / "backlog" / "born-offline.md").write_text("offline\n")
3566
+ (repo / "work" / "versions" / "backlog").mkdir(parents=True, exist_ok=True)
3567
+ (repo / "work" / "versions" / "backlog" / "born-offline.md").write_text("offline\n")
3530
3568
  committed, pushed, _ = git.land(repo, "born-offline", [])
3531
3569
  assert committed and not pushed
3532
3570
  git.GIT = {**git.GIT, "push": True}
@@ -3553,9 +3591,9 @@ def test_a_board_write_that_landed_never_reports_as_one_that_failed():
3553
3591
  with tempfile.TemporaryDirectory() as tmp:
3554
3592
  try:
3555
3593
  repo = _git_repo(tmp, push=False)
3556
- (repo / "work" / "backlog").mkdir(parents=True, exist_ok=True)
3557
- (repo / "work" / "backlog" / "first.md").write_text("first\n")
3558
- (repo / "work" / "backlog" / "second.md").write_text("second\n")
3594
+ (repo / "work" / "versions" / "backlog").mkdir(parents=True, exist_ok=True)
3595
+ (repo / "work" / "versions" / "backlog" / "first.md").write_text("first\n")
3596
+ (repo / "work" / "versions" / "backlog" / "second.md").write_text("second\n")
3559
3597
  rows = [{"event": "created", "name": "first"}]
3560
3598
  committed, _, note = git.land(repo, "first", rows)
3561
3599
  assert committed and note == ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -61,12 +61,12 @@
61
61
  "@jarvis/data": "0.1.0",
62
62
  "@jarvis/errors": "1.0.0",
63
63
  "@jarvis/logger": "1.0.0",
64
- "@jarvis/board": "0.1.0",
65
64
  "@jarvis/rpc": "1.0.0",
66
- "@jarvis/types": "1.0.0",
65
+ "@jarvis/board": "0.1.0",
67
66
  "@jarvis/typescript-config": "1.0.0",
68
- "@jarvis/vitest-config": "1.0.0",
69
- "@jarvis/ui": "0.1.0"
67
+ "@jarvis/types": "1.0.0",
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",