@appchy/jarvis 0.1.70 → 0.1.72

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.
@@ -94,7 +94,10 @@ def repair(before: dict, moves: dict) -> tuple:
94
94
  continue
95
95
 
96
96
  text = original = now.read_text(errors="ignore")
97
- for href, target, existed in links:
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):
98
101
  if not existed:
99
102
  continue
100
103
  want = _after(target, moves)
@@ -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)
@@ -334,7 +334,19 @@ def cmd_move(args) -> int:
334
334
  if dest.exists():
335
335
  die(f"{rel(dest, root)} already exists")
336
336
  dest.parent.mkdir(parents=True, exist_ok=True)
337
+
338
+ # The bucket is part of every path that names this task, so a status change
339
+ # moves the folder and breaks whatever pointed at it — and `move` fires on every
340
+ # pickup and every completion, far more often than `place`. One audited board
341
+ # had 16 of its 37 broken links from a bucket change alone.
342
+ before = links.snapshot(root.parent)
343
+ moved_from = task.folder.resolve()
337
344
  shutil.move(str(task.folder), str(dest))
345
+ repaired, unresolved = links.repair(before, {moved_from: dest.resolve()})
346
+ if repaired:
347
+ print(f" repaired {repaired} link(s) that pointed at it")
348
+ for line in unresolved:
349
+ print(f" COULD NOT PLACE {line}")
338
350
 
339
351
  def mutate(d):
340
352
  d["updated"] = date.today().isoformat()
@@ -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)
@@ -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,45 @@ 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
+
1781
1820
  def test_links_are_repaired_by_what_they_pointed_at_not_by_what_they_say():
1782
1821
  from harness import links
1783
1822
  with tempfile.TemporaryDirectory() as tmp:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
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/anthropic": "1.0.0",
60
+ "@jarvis/board": "0.1.0",
61
61
  "@jarvis/data": "0.1.0",
62
- "@jarvis/errors": "1.0.0",
63
62
  "@jarvis/logger": "1.0.0",
64
63
  "@jarvis/rpc": "1.0.0",
65
- "@jarvis/board": "0.1.0",
66
64
  "@jarvis/typescript-config": "1.0.0",
67
- "@jarvis/types": "1.0.0",
68
65
  "@jarvis/ui": "0.1.0",
69
- "@jarvis/vitest-config": "1.0.0"
66
+ "@jarvis/vitest-config": "1.0.0",
67
+ "@jarvis/anthropic": "1.0.0",
68
+ "@jarvis/types": "1.0.0",
69
+ "@jarvis/errors": "1.0.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",