@appchy/jarvis 0.1.69 → 0.1.71

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.
@@ -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,143 @@
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
+ # One entry per distinct link: a href repeated in a file is rewritten
98
+ # everywhere by the first replace, and counting the rest as failures
99
+ # reports a repair that worked as one that did not.
100
+ for href, target, existed in dict.fromkeys(links):
101
+ if not existed:
102
+ continue
103
+ want = _after(target, moves)
104
+ if want == target and now == doc:
105
+ continue # neither end moved
106
+
107
+ path, _, anchor = href.partition("#")
108
+ fixed = os.path.relpath(want, now.parent) + (f"#{anchor}" if anchor else "")
109
+ if fixed == href:
110
+ continue
111
+ # Anchored on the link's own syntax, so a path that also appears in
112
+ # prose is not rewritten by accident.
113
+ if f"]({href})" not in text:
114
+ unresolved.append(f"{now} — {href}")
115
+ continue
116
+ text = text.replace(f"]({href})", f"]({fixed})")
117
+ repaired += 1
118
+
119
+ if text != original:
120
+ now.write_text(text)
121
+
122
+ return repaired, unresolved
123
+
124
+
125
+ def stale_mentions(repo: Path, old_name: str, moved_into: Path) -> list:
126
+ """Prose still naming a folder that has moved — the part no link check sees.
127
+
128
+ A rename does not only break links. `08` meant one cut before a renumber and
129
+ another after it, and every sentence naming it kept reading as correct. These
130
+ are reported, never edited: whether a mention is stale or a deliberate record
131
+ of what was true then is a question only a person can answer.
132
+ """
133
+ hits = []
134
+ for doc in _docs(repo):
135
+ if moved_into in doc.parents:
136
+ continue
137
+ try:
138
+ for n, line in enumerate(doc.read_text(errors="ignore").splitlines(), 1):
139
+ if old_name in line and f"]({old_name}" not in line:
140
+ hits.append(f"{doc.relative_to(repo)}:{n}")
141
+ except OSError:
142
+ continue
143
+ 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
@@ -310,11 +310,11 @@ def locate(root: Path, name: str):
310
310
  return t
311
311
  return None
312
312
  def locate_version(root: Path, name: str):
313
- """Find a version by name. Looks in `archive/versions/` too, so a released
313
+ """Find a version by name. Looks in `versions/archive/` too, so a finished
314
314
  cut stays LOOKUPABLE after it leaves the board — `path` and `where` keep
315
315
  answering, while `scan` (and therefore `list`, the README and every lint)
316
316
  sees only live work. Archived is off the board, not gone."""
317
- for base in ("versions", "archive/versions"):
317
+ for base in ("versions", "versions/archive"):
318
318
  folder = root / base / name
319
319
  if (folder / "version.md").is_file():
320
320
  return Version(folder)
@@ -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:
@@ -5,13 +5,13 @@ from datetime import date
5
5
  from pathlib import Path
6
6
 
7
7
  from . import ids
8
- from .tree import DONE_TIER, VERSION_FM_ORDER, cli, die, find_work_root, rel
8
+ from .tree import archive_dir, DONE_TIER, VERSION_FM_ORDER, cli, die, find_work_root, rel
9
9
  from .frontmatter import rewrite_file
10
10
  from .model import _is_epic_dir, locate_version, scan
11
11
  from .scaffold import _check_unused, _check_version_name, _scaffold_version
12
12
  from .epic import cmd_epic_release
13
13
  from .generate import _sync
14
- from . import events
14
+ from . import events, links
15
15
 
16
16
 
17
17
  def cmd_version_new(args) -> int:
@@ -231,8 +231,17 @@ def cmd_archive(args) -> int:
231
231
  version = locate_version(root, name)
232
232
  if not version:
233
233
  die(f"no version named '{name}'")
234
- if not version.released:
235
- die(f"version '{name}' is not released release it before archiving")
234
+ # A cut whose work all moved elsewhere has nothing left to deliver and no
235
+ # outcome it honestly met, so releasing it would be a lie — and until now
236
+ # archiving it was refused, which left it in the live list looking open. One
237
+ # repo had two such cuts holding 87 completed tasks between them. What is
238
+ # actually being asked is whether anything is still owed, so ask that.
239
+ open_work = [t for t in version.all_tasks() if t.status != "complete"]
240
+ if not version.released and open_work:
241
+ die(f"version '{name}' is not released and still holds "
242
+ f"{len(open_work)} unfinished task(s) — release it, or move that work "
243
+ f"somewhere it can be finished. A cut leaves the board when nothing is "
244
+ f"owed on it, not when somebody stops looking at it.")
236
245
  if version.fm.get("archived"):
237
246
  die(f"version '{name}' is already archived ({version.fm['archived']})")
238
247
 
@@ -267,7 +276,7 @@ def cmd_archive(args) -> int:
267
276
  removed += 1
268
277
 
269
278
  if dry:
270
- dest = root / "archive" / "versions" / name
279
+ dest = archive_dir(root) / name
271
280
  print(f" stamp archived: on {rel(version.md, root)}")
272
281
  print(f" move {rel(version.folder, root)} -> {rel(dest, root)}")
273
282
  print(f"\n {removed} file(s)/dir(s) would be stripped. "
@@ -291,11 +300,18 @@ def cmd_archive(args) -> int:
291
300
  # past 60 completed tasks to find the 6 that were actually moving. The
292
301
  # record is preserved verbatim in `archive/versions/<v>/`, where `path` and
293
302
  # `where` still resolve it — it is just no longer in the way.
294
- dest = _contained(root / "archive" / "versions" / name, root)
303
+ dest = _contained(archive_dir(root) / name, root)
295
304
  if dest.exists():
296
305
  die(f"{rel(dest, root)} already exists")
297
306
  dest.parent.mkdir(parents=True, exist_ok=True)
307
+ before = links.snapshot(root.parent)
308
+ moved_from = version.folder.resolve()
298
309
  shutil.move(str(_contained(version.folder, root)), str(dest))
310
+ repaired, unresolved = links.repair(before, {moved_from: dest.resolve()})
311
+ if repaired:
312
+ print(f" repaired {repaired} link(s) that pointed into it")
313
+ for line in unresolved:
314
+ print(f" COULD NOT PLACE {line}")
299
315
 
300
316
  where = f" — full docs in git history @ {sha}" if sha else ""
301
317
  events.append(root, "archived", name, stripped=removed)
@@ -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
 
@@ -953,7 +953,7 @@ def test_archive_moves_the_version_off_the_board_but_keeps_it_lookupable():
953
953
  os.environ.pop("WORK_DIR")
954
954
 
955
955
  assert not (root / "versions" / "26-cut").exists()
956
- dest = root / "archive" / "versions" / "26-cut"
956
+ dest = root / "versions" / "archive" / "26-cut"
957
957
  assert (dest / "version.md").is_file()
958
958
  # stripped to task.md, and the epic grouping survives the move
959
959
  assert (dest / "an-epic" / "complete" / "shipped-thing" / "task.md").is_file()
@@ -1778,6 +1778,81 @@ 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_a_finished_cut_leaves_the_board_without_pretending_it_shipped():
1782
+ # A cut whose work all moved elsewhere met no outcome, so releasing it is a lie
1783
+ # — and archiving used to be refused without a release, which left it in the
1784
+ # live list looking open. One repo had two such cuts holding 87 done tasks.
1785
+ import io, contextlib, os
1786
+ with tempfile.TemporaryDirectory() as tmp:
1787
+ v = _tree(tmp, name="01-spent")
1788
+ root = Path(tmp)
1789
+ e = _epic(v, "an-epic")
1790
+ (e / "complete").mkdir()
1791
+ _task(e / "complete", "shipped-this")
1792
+
1793
+ os.environ["WORK_DIR"] = tmp
1794
+ try:
1795
+ with contextlib.redirect_stdout(io.StringIO()):
1796
+ assert version.cmd_archive({"name": "01-spent"}) == 0
1797
+ assert (root / "versions" / "archive" / "01-spent").is_dir(), \
1798
+ "archive lives inside versions/, beside the cuts"
1799
+ assert not (root / "versions" / "01-spent").exists()
1800
+
1801
+ # Still owed work is the thing that actually blocks it, not the absence
1802
+ # of a release stamp.
1803
+ v2 = root / "versions" / "02-busy"
1804
+ v2.mkdir()
1805
+ (v2 / "version.md").write_text(
1806
+ "---\ncreated: 2026-08-01\norder: 02\noutcome: b\n---\n\n# 02-busy\n")
1807
+ e2 = _epic(v2, "another-epic")
1808
+ (e2 / "queue").mkdir()
1809
+ _task(e2 / "queue", "still-owed")
1810
+ with contextlib.redirect_stdout(io.StringIO()):
1811
+ try:
1812
+ version.cmd_archive({"name": "02-busy"})
1813
+ raise AssertionError("archived a cut that still owes work")
1814
+ except SystemExit:
1815
+ pass
1816
+ finally:
1817
+ os.environ.pop("WORK_DIR", None)
1818
+
1819
+
1820
+ def test_links_are_repaired_by_what_they_pointed_at_not_by_what_they_say():
1821
+ from harness import links
1822
+ with tempfile.TemporaryDirectory() as tmp:
1823
+ repo = Path(tmp)
1824
+ (repo / "a" / "deep").mkdir(parents=True)
1825
+ (repo / "b").mkdir()
1826
+ (repo / "target.md").write_text("# target\n")
1827
+ (repo / "b" / "other.md").write_text("# other\n")
1828
+
1829
+ # Three links from one file: one at a thing that will move, one at a thing
1830
+ # that will not, and one that is already dead.
1831
+ doc = repo / "a" / "note.md"
1832
+ doc.write_text(
1833
+ "See [it](../target.md), and [other](../b/other.md), "
1834
+ "and [gone](../nowhere.md).\n")
1835
+
1836
+ before = links.snapshot(repo)
1837
+ moved_to = repo / "a" / "deep" / "target.md"
1838
+ (repo / "target.md").rename(moved_to)
1839
+ repaired, unresolved = links.repair(
1840
+ before, {(repo / "target.md").resolve(): moved_to.resolve()})
1841
+
1842
+ text = doc.read_text()
1843
+ assert "](deep/target.md)" in text, f"target moved, link did not follow: {text}"
1844
+ assert "](../b/other.md)" in text, "a link at something that did not move was touched"
1845
+ assert "](../nowhere.md)" in text, "a link dead before the move was invented a target"
1846
+ assert repaired == 1 and unresolved == []
1847
+
1848
+ # The class nothing else catches: the old name now belongs to a DIFFERENT
1849
+ # real file, so the stale pointer still resolves and reads as correct.
1850
+ (repo / "target.md").write_text("# an impostor now wearing the old name\n")
1851
+ assert (repo / "a" / "../target.md").exists()
1852
+ assert "](deep/target.md)" in doc.read_text(), \
1853
+ "identity, not resolvability, is what the repair preserves"
1854
+
1855
+
1781
1856
  def test_place_carries_a_tasks_bucket_into_the_new_cut():
1782
1857
  # The bucket IS the status, so landing everything in `queue/` does not move a
1783
1858
  # task, it un-starts it — silently, and for blocked work the obvious repair is
@@ -1945,7 +2020,9 @@ def test_init_scaffolds_the_whole_tree_and_is_idempotent_and_non_destructive():
1945
2020
  assert (root / "README.md").is_file() and (root / "ROADMAP.md").is_file()
1946
2021
  assert tree.BACKLOG_START in (root / "README.md").read_text(), \
1947
2022
  "`_regen_readme` dies without the markers, so `init` must write them"
1948
- for d in ("versions", "backlog"):
2023
+ # backlog and archive are STATES a cut's work is in, so they live inside
2024
+ # versions/ beside the cuts rather than as siblings of it.
2025
+ for d in ("versions", "versions/backlog", "versions/archive"):
1949
2026
  assert (root / d).is_dir()
1950
2027
  assert len(registry.scan_domains(root)) == 9
1951
2028
 
@@ -3497,8 +3574,8 @@ def test_a_gitignored_board_is_the_failure_wearing_a_success_s_clothes():
3497
3574
  _git(repo, "rm", "-r", "-q", "--cached", "work")
3498
3575
  _git(repo, "add", ".gitignore")
3499
3576
  _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")
3577
+ (repo / "work" / "versions" / "backlog").mkdir(parents=True, exist_ok=True)
3578
+ (repo / "work" / "versions" / "backlog" / "an-item.md").write_text("a board write\n")
3502
3579
 
3503
3580
  assert git.ignored(repo) == ["work"], \
3504
3581
  "an ignored board must be nameable, or nothing can report it"
@@ -3525,8 +3602,8 @@ def test_sync_recovers_a_branch_that_never_had_an_upstream():
3525
3602
  _git(repo, "checkout", "-qb", "never-pushed")
3526
3603
  assert _git(repo, "rev-parse", "--abbrev-ref", "@{upstream}").returncode != 0, \
3527
3604
  "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")
3605
+ (repo / "work" / "versions" / "backlog").mkdir(parents=True, exist_ok=True)
3606
+ (repo / "work" / "versions" / "backlog" / "born-offline.md").write_text("offline\n")
3530
3607
  committed, pushed, _ = git.land(repo, "born-offline", [])
3531
3608
  assert committed and not pushed
3532
3609
  git.GIT = {**git.GIT, "push": True}
@@ -3553,9 +3630,9 @@ def test_a_board_write_that_landed_never_reports_as_one_that_failed():
3553
3630
  with tempfile.TemporaryDirectory() as tmp:
3554
3631
  try:
3555
3632
  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")
3633
+ (repo / "work" / "versions" / "backlog").mkdir(parents=True, exist_ok=True)
3634
+ (repo / "work" / "versions" / "backlog" / "first.md").write_text("first\n")
3635
+ (repo / "work" / "versions" / "backlog" / "second.md").write_text("second\n")
3559
3636
  rows = [{"event": "created", "name": "first"}]
3560
3637
  committed, _, note = git.land(repo, "first", rows)
3561
3638
  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.71",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -58,15 +58,15 @@
58
58
  "vitest": "^2.1.0",
59
59
  "@jarvis/agents": "1.0.0",
60
60
  "@jarvis/anthropic": "1.0.0",
61
+ "@jarvis/board": "0.1.0",
61
62
  "@jarvis/data": "0.1.0",
62
63
  "@jarvis/errors": "1.0.0",
63
64
  "@jarvis/logger": "1.0.0",
64
- "@jarvis/board": "0.1.0",
65
65
  "@jarvis/rpc": "1.0.0",
66
66
  "@jarvis/types": "1.0.0",
67
67
  "@jarvis/typescript-config": "1.0.0",
68
- "@jarvis/vitest-config": "1.0.0",
69
- "@jarvis/ui": "0.1.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",