@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.
- package/dist/bin.js +15 -21
- package/dist/bin.js.map +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 +143 -0
- package/harness/harness/model.py +4 -4
- package/harness/harness/scaffold.py +14 -3
- package/harness/harness/task.py +15 -0
- package/harness/harness/tree.py +15 -2
- package/harness/harness/version.py +22 -6
- package/harness/test_work.py +97 -20
- package/package.json +4 -4
|
@@ -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,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
|
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
|
|
@@ -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
|
|
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
|
|
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
|
-
"""
|
|
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:
|
|
@@ -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
|
-
|
|
235
|
-
|
|
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 /
|
|
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 /
|
|
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)
|
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
|
|
|
@@ -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 / "
|
|
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
|
-
|
|
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.
|
|
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/
|
|
69
|
-
"@jarvis/
|
|
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",
|