@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 +1 -1
- package/harness/harness/architecture.py +1 -1
- package/harness/harness/branches.py +3 -3
- package/harness/harness/epic.py +3 -3
- package/harness/harness/links.py +140 -0
- package/harness/harness/model.py +2 -2
- package/harness/harness/scaffold.py +14 -3
- package/harness/harness/task.py +15 -0
- package/harness/harness/tree.py +15 -2
- package/harness/test_work.py +57 -19
- package/package.json +5 -5
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 = "
|
|
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/
|
|
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[
|
|
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[
|
|
65
|
+
"epic": parts[3] if len(parts) > 3 else "",
|
|
66
66
|
"version": "backlog" if backlog else parts[2],
|
|
67
67
|
"path": path,
|
|
68
68
|
})
|
package/harness/harness/epic.py
CHANGED
|
@@ -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 /
|
|
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 /
|
|
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
|
package/harness/harness/model.py
CHANGED
|
@@ -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
|
|
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
|
-
"""
|
|
14
|
-
|
|
15
|
-
|
|
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)}")
|
package/harness/harness/task.py
CHANGED
|
@@ -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,
|
package/harness/harness/tree.py
CHANGED
|
@@ -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
|
|
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:
|
package/harness/test_work.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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/
|
|
65
|
+
"@jarvis/board": "0.1.0",
|
|
67
66
|
"@jarvis/typescript-config": "1.0.0",
|
|
68
|
-
"@jarvis/
|
|
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",
|