@appchy/jarvis 0.1.73 → 0.1.75
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/dist/ui/app.js +11 -11
- package/harness/harness/align.py +28 -0
- package/harness/harness/config.py +1 -0
- package/harness/harness/migrate.py +56 -6
- package/harness/test_work.py +73 -0
- package/harness/work.py +12 -2
- package/package.json +5 -5
package/harness/harness/align.py
CHANGED
|
@@ -17,6 +17,10 @@ from .lint import _feature_ac_ids, feature_ac_levels
|
|
|
17
17
|
#: exists to remove.
|
|
18
18
|
FOREIGN_REPOS: tuple = ()
|
|
19
19
|
|
|
20
|
+
#: This repo's own standards file, relative to the repo root. Set from config
|
|
21
|
+
#: (`spine.standards`); empty when a repo keeps no second ledger.
|
|
22
|
+
STANDARDS: str = ""
|
|
23
|
+
|
|
20
24
|
#: Directories under `work/` that are NOT durable governance. `product/` and
|
|
21
25
|
#: `architecture/` are handled on their own (they are the two sides the
|
|
22
26
|
#: single-feature check compares); the rest here hold work-in-flight or history,
|
|
@@ -156,6 +160,29 @@ def _foreign_qualifier() -> re.Pattern:
|
|
|
156
160
|
return re.compile(r"(?!)")
|
|
157
161
|
alt = "|".join(re.escape(r) for r in FOREIGN_REPOS)
|
|
158
162
|
return re.compile(rf"(?:{alt})\s*[:=]?\s*[`*\[(\"']{{0,3}}$", re.IGNORECASE)
|
|
163
|
+
def _standards_ids(root: Path) -> set:
|
|
164
|
+
"""Ids this repo declares in its standards file, which does not live under `work/`.
|
|
165
|
+
|
|
166
|
+
Without this the check walked the board alone and reported every one of them as
|
|
167
|
+
resolving to nothing: twenty in this repo, twelve in a sibling, all false, all
|
|
168
|
+
cited by live briefs that were reading the rules correctly. They resolve
|
|
169
|
+
perfectly — they are declared at the repo root, in the file the config already
|
|
170
|
+
names.
|
|
171
|
+
|
|
172
|
+
Twenty false errors on top of three real warnings is worse than a wrong number:
|
|
173
|
+
it is how somebody learns the report is noise and stops reading the part that
|
|
174
|
+
was telling them something.
|
|
175
|
+
|
|
176
|
+
`doctor`'s id check reaches for the same pointer, so the two cannot disagree
|
|
177
|
+
about what counts as declared here.
|
|
178
|
+
"""
|
|
179
|
+
from . import config # deferred — `config.apply` imports this module
|
|
180
|
+
if not STANDARDS:
|
|
181
|
+
return set()
|
|
182
|
+
named = config.standards_index({"spine": {"standards": STANDARDS}}, root.parent)
|
|
183
|
+
return {ids.normalise(i) for i, _ in named}
|
|
184
|
+
|
|
185
|
+
|
|
159
186
|
def _align_citations(root: Path) -> list:
|
|
160
187
|
"""A cited id that resolves to nothing."""
|
|
161
188
|
out = []
|
|
@@ -165,6 +192,7 @@ def _align_citations(root: Path) -> list:
|
|
|
165
192
|
defined = set(definition_sites(root))
|
|
166
193
|
for _, _, rules, _ in hosts(root):
|
|
167
194
|
defined |= set(rules)
|
|
195
|
+
defined |= _standards_ids(root)
|
|
168
196
|
foreign = _foreign_qualifier()
|
|
169
197
|
dangling: dict = {}
|
|
170
198
|
for p in live_surfaces(root):
|
|
@@ -623,6 +623,7 @@ def apply(cfg: dict) -> None:
|
|
|
623
623
|
kickoff.SPINE = [s for s in ([cfg["spine"]["standards"]] if cfg["spine"]["standards"]
|
|
624
624
|
else []) + list(cfg["spine"]["conventions"]) if s]
|
|
625
625
|
align.FOREIGN_REPOS = tuple(cfg["ids"]["foreign"])
|
|
626
|
+
align.STANDARDS = cfg["spine"]["standards"] or ""
|
|
626
627
|
registry.DOMAIN_ORDER = tuple(cfg["domains"]["order"])
|
|
627
628
|
# From the SHIPPED set each time, never from whatever the last `apply` left
|
|
628
629
|
# behind. Unioning into the live value made a second call in one process keep the
|
|
@@ -51,11 +51,26 @@ def _uncommitted(repo: Path, work: Path) -> list:
|
|
|
51
51
|
return sorted(dirty)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _archived_cuts(root: Path) -> list:
|
|
55
|
+
"""Cuts sitting in the archive's former home, `work/archive/versions/<cut>/`.
|
|
56
|
+
|
|
57
|
+
Every repo that ever archived a cut has these, and the layout change made them
|
|
58
|
+
UNREACHABLE: `locate_version` looks in `versions/` and `versions/archive/`, so
|
|
59
|
+
`path` and `where` answer "no version named that" for a record the repo still
|
|
60
|
+
holds. jarvis had archived nothing, which is why the first migration never met
|
|
61
|
+
this and three siblings each have seven to eleven of them.
|
|
62
|
+
"""
|
|
63
|
+
old = root / "archive" / "versions"
|
|
64
|
+
if not old.is_dir():
|
|
65
|
+
return []
|
|
66
|
+
return sorted(p for p in old.iterdir() if (p / "version.md").is_file())
|
|
67
|
+
|
|
68
|
+
|
|
54
69
|
def cmd_migrate(args) -> int:
|
|
55
|
-
"""`work/backlog/`
|
|
70
|
+
"""`work/backlog/` and the archive both move inside `versions/`.
|
|
56
71
|
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
One layout: backlog and archive are states a cut's work is in, so they belong
|
|
73
|
+
beside the cuts rather than in directories of their own.
|
|
59
74
|
"""
|
|
60
75
|
root = find_work_root()
|
|
61
76
|
repo = root.parent
|
|
@@ -64,18 +79,26 @@ def cmd_migrate(args) -> int:
|
|
|
64
79
|
old = root / "backlog"
|
|
65
80
|
new = backlog_dir(root)
|
|
66
81
|
archive = archive_dir(root)
|
|
82
|
+
cuts = _archived_cuts(root)
|
|
67
83
|
|
|
68
|
-
# The
|
|
69
|
-
# or a repo has grown a second
|
|
70
|
-
# about which copy
|
|
84
|
+
# The hard refusals. Two of anything means somebody has already started this,
|
|
85
|
+
# or a repo has grown a second copy — and merging two trees is a judgement
|
|
86
|
+
# about which copy is real, which is not a call a migration makes.
|
|
71
87
|
if old.is_dir() and new.is_dir():
|
|
72
88
|
die(f"both {rel(old, root)} and {rel(new, root)} exist — this repo has two "
|
|
73
89
|
f"backlogs and only a person can say which task is the real one. "
|
|
74
90
|
f"Merge them by hand, then run this again.")
|
|
91
|
+
clashes = [c.name for c in cuts if (archive / c.name).exists()]
|
|
92
|
+
if clashes:
|
|
93
|
+
die(f"{len(clashes)} cut(s) are archived in both places: "
|
|
94
|
+
f"{', '.join(clashes)}. Which record is the real one is yours to say — "
|
|
95
|
+
f"merge them by hand, then run this again.")
|
|
75
96
|
|
|
76
97
|
todo = []
|
|
77
98
|
if old.is_dir():
|
|
78
99
|
todo.append("the backlog moves inside versions/")
|
|
100
|
+
if cuts:
|
|
101
|
+
todo.append(f"{len(cuts)} archived cut(s) move inside versions/")
|
|
79
102
|
if not archive.is_dir():
|
|
80
103
|
todo.append("versions/archive/ is created")
|
|
81
104
|
if not todo:
|
|
@@ -88,6 +111,8 @@ def cmd_migrate(args) -> int:
|
|
|
88
111
|
print(f" {line}")
|
|
89
112
|
if old.is_dir():
|
|
90
113
|
print(f" {sum(1 for _ in old.rglob('task.md'))} task(s) would move")
|
|
114
|
+
for c in cuts:
|
|
115
|
+
print(f" archived: {c.name}")
|
|
91
116
|
return 0
|
|
92
117
|
|
|
93
118
|
dirty = _uncommitted(repo, root)
|
|
@@ -124,6 +149,31 @@ def cmd_migrate(args) -> int:
|
|
|
124
149
|
(archive / ".gitkeep").write_text("")
|
|
125
150
|
print(f"created {rel(archive, root)}")
|
|
126
151
|
|
|
152
|
+
if cuts:
|
|
153
|
+
# One block for every cut. Each carries a whole released version's worth of
|
|
154
|
+
# documents, and the links between them are the reason this is worth doing
|
|
155
|
+
# properly rather than with a `mv`.
|
|
156
|
+
with links.repairing(repo) as moved:
|
|
157
|
+
for c in cuts:
|
|
158
|
+
moved[c.resolve()] = (archive / c.name).resolve()
|
|
159
|
+
shutil.move(str(c), str(archive / c.name))
|
|
160
|
+
print(f"moved {len(cuts)} archived cut(s) -> {rel(archive, root)}")
|
|
161
|
+
|
|
162
|
+
# What is left under the old archive is NOT the harness's. One repo keeps
|
|
163
|
+
# an `archive/backlog/` of its own there; moving it would be a guess about
|
|
164
|
+
# somebody else's records, and deleting the directory would take it with.
|
|
165
|
+
stale_home = root / "archive"
|
|
166
|
+
leftover = sorted(p.name for p in stale_home.iterdir()) if stale_home.is_dir() else []
|
|
167
|
+
leftover = [n for n in leftover if n != "versions"]
|
|
168
|
+
versions_dir = stale_home / "versions"
|
|
169
|
+
if versions_dir.is_dir() and not any(versions_dir.iterdir()):
|
|
170
|
+
versions_dir.rmdir()
|
|
171
|
+
if leftover:
|
|
172
|
+
print(f" {rel(stale_home, root)} still holds {', '.join(leftover)} — "
|
|
173
|
+
f"the harness does not own those, so they were left alone")
|
|
174
|
+
elif stale_home.is_dir() and not any(stale_home.iterdir()):
|
|
175
|
+
stale_home.rmdir()
|
|
176
|
+
|
|
127
177
|
print("done — commit this, then every machine working this repo needs a CLI "
|
|
128
178
|
"new enough to read it")
|
|
129
179
|
return 0
|
package/harness/test_work.py
CHANGED
|
@@ -284,6 +284,29 @@ def test_a_doc_narrating_a_retirement_is_not_a_dangling_citation():
|
|
|
284
284
|
assert not [f for f in align._align_citations(root) if f[1] == "dangling-citation"]
|
|
285
285
|
|
|
286
286
|
|
|
287
|
+
def test_a_standard_declared_at_the_repo_root_is_not_a_dangling_citation():
|
|
288
|
+
# `align` walked `work/` alone, so every id declared in the standards file at
|
|
289
|
+
# the repo ROOT read as resolving to nothing — twenty here, twelve in a
|
|
290
|
+
# sibling, all false and all cited by briefs that were reading the rules
|
|
291
|
+
# correctly. Twenty false errors on three real warnings is how somebody
|
|
292
|
+
# learns the report is noise.
|
|
293
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
294
|
+
root = _citing_repo(tmp, "Written to `S-1`, and also `S-9`.\n")
|
|
295
|
+
(Path(tmp) / "STANDARDS.md").write_text(
|
|
296
|
+
"# Standards\n\n## S-1. Function signatures\n\ntext\n")
|
|
297
|
+
ids.configure("D", ("S",))
|
|
298
|
+
align.STANDARDS = "STANDARDS.md"
|
|
299
|
+
try:
|
|
300
|
+
found = [m for sev, c, m in align._align_citations(root)
|
|
301
|
+
if c == "dangling-citation"]
|
|
302
|
+
finally:
|
|
303
|
+
align.STANDARDS = ""
|
|
304
|
+
assert not any("S-1" in m for m in found), found
|
|
305
|
+
# And the guard: a standards-shaped id the file does not declare is still
|
|
306
|
+
# reported, or this would be an amnesty rather than a lookup.
|
|
307
|
+
assert any("S-9" in m for m in found), found
|
|
308
|
+
|
|
309
|
+
|
|
287
310
|
def test_a_real_dangling_id_still_reports_in_a_file_that_retires_another():
|
|
288
311
|
# The guard on the check above: file-scoped narration must not become a
|
|
289
312
|
# blanket amnesty for every id the document happens to mention.
|
|
@@ -5482,6 +5505,30 @@ def test_an_epic_move_snapshots_the_repo_once_not_once_per_task():
|
|
|
5482
5505
|
assert len(calls) == 1, f"scanned the repo {len(calls)} times for 3 tasks"
|
|
5483
5506
|
|
|
5484
5507
|
|
|
5508
|
+
def test_the_config_is_found_from_a_subdirectory_of_the_repo():
|
|
5509
|
+
# The tree search walks up for a `work/`; the config search stopped at the cwd.
|
|
5510
|
+
# So standing one directory inside a repo found no config and every check ran
|
|
5511
|
+
# in the shipped dialect against the right tree — `align` from the root
|
|
5512
|
+
# reported twenty citations and from `apps/cli/` reported thirteen entirely
|
|
5513
|
+
# different ones. One tree, two answers, decided by where you stood.
|
|
5514
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
5515
|
+
repo = Path(tmp) / "repo"
|
|
5516
|
+
(repo / "work" / "versions").mkdir(parents=True)
|
|
5517
|
+
deep = repo / "src" / "deep"
|
|
5518
|
+
deep.mkdir(parents=True)
|
|
5519
|
+
|
|
5520
|
+
was = Path.cwd()
|
|
5521
|
+
env = {k: os.environ.pop(k) for k in ("CLAUDE_PROJECT_DIR", "WORK_DIR")
|
|
5522
|
+
if k in os.environ}
|
|
5523
|
+
os.chdir(deep)
|
|
5524
|
+
try:
|
|
5525
|
+
assert entry._project_root({}) == repo.resolve(), \
|
|
5526
|
+
"the config search stopped at the cwd instead of finding the repo"
|
|
5527
|
+
finally:
|
|
5528
|
+
os.chdir(was)
|
|
5529
|
+
os.environ.update(env)
|
|
5530
|
+
|
|
5531
|
+
|
|
5485
5532
|
def _migrate(tmp: str, **flags):
|
|
5486
5533
|
"""`migrate` against a fixture, with WORK_DIR pointing at it."""
|
|
5487
5534
|
from harness import migrate as migrate_mod
|
|
@@ -5520,6 +5567,32 @@ def test_migrate_moves_the_backlog_inside_versions_and_repairs_what_pointed_at_i
|
|
|
5520
5567
|
f"a link outside work/ was left pointing at {href}"
|
|
5521
5568
|
|
|
5522
5569
|
|
|
5570
|
+
def test_migrate_brings_already_archived_cuts_to_where_lookups_look():
|
|
5571
|
+
# The layout change moved where `archive` WRITES without moving what it had
|
|
5572
|
+
# already written, and `locate_version` only reads the new place — so every
|
|
5573
|
+
# cut a repo had archived answered "no version named that". jarvis had
|
|
5574
|
+
# archived none, which is why this survived the first migration.
|
|
5575
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
5576
|
+
_tree(tmp)
|
|
5577
|
+
root = Path(tmp)
|
|
5578
|
+
(root / "versions" / "backlog").rmdir()
|
|
5579
|
+
(root / "backlog").mkdir()
|
|
5580
|
+
old = root / "archive" / "versions" / "09-shipped"
|
|
5581
|
+
old.mkdir(parents=True)
|
|
5582
|
+
(old / "version.md").write_text(
|
|
5583
|
+
"---\ncreated: 2026-01-01\norder: 9\noutcome: x\nreleased: 2026-02-01\n"
|
|
5584
|
+
"---\n\n# Shipped\n")
|
|
5585
|
+
# Something the harness does not own, in the same directory.
|
|
5586
|
+
(root / "archive" / "backlog" / "an-old-idea").mkdir(parents=True)
|
|
5587
|
+
|
|
5588
|
+
assert _migrate(tmp) == 0
|
|
5589
|
+
assert (root / "versions" / "archive" / "09-shipped" / "version.md").is_file()
|
|
5590
|
+
assert model.locate_version(root, "09-shipped") is not None, \
|
|
5591
|
+
"an archived cut is still not lookupable after migrating"
|
|
5592
|
+
assert (root / "archive" / "backlog" / "an-old-idea").is_dir(), \
|
|
5593
|
+
"the migration moved records the harness does not own"
|
|
5594
|
+
|
|
5595
|
+
|
|
5523
5596
|
def test_migrate_says_nothing_to_do_on_a_repo_already_migrated():
|
|
5524
5597
|
# Nobody remembers which repos have been through it, so the safe answer to
|
|
5525
5598
|
# running it twice has to be "nothing", not a second move or an error.
|
package/harness/work.py
CHANGED
|
@@ -193,7 +193,16 @@ def parse_argv(argv):
|
|
|
193
193
|
def _project_root(flags) -> Path:
|
|
194
194
|
"""The repo the harness is acting on. `--project` wins, then the env, then the
|
|
195
195
|
cwd — the same precedence `find_work_root` uses, so config and tree can never
|
|
196
|
-
disagree about which repo this is.
|
|
196
|
+
disagree about which repo this is.
|
|
197
|
+
|
|
198
|
+
The last step WALKS UP, and that is the half this claimed and did not do. The
|
|
199
|
+
tree search walks up for a `work/`; this stopped at the cwd, so standing one
|
|
200
|
+
directory into a repo found no config and every check ran in the shipped
|
|
201
|
+
dialect against the right tree. Measured here: `align` from the root reported
|
|
202
|
+
twenty citations, and from `apps/cli/` reported thirteen entirely different
|
|
203
|
+
ones, having stopped recognising this repo's own ids altogether. One tree, two
|
|
204
|
+
answers, decided by where the caller happened to be standing.
|
|
205
|
+
"""
|
|
197
206
|
import os
|
|
198
207
|
p = flags.get("project") or os.environ.get("CLAUDE_PROJECT_DIR")
|
|
199
208
|
if p:
|
|
@@ -201,7 +210,8 @@ def _project_root(flags) -> Path:
|
|
|
201
210
|
wd = os.environ.get("WORK_DIR")
|
|
202
211
|
if wd:
|
|
203
212
|
return Path(wd).resolve().parent
|
|
204
|
-
|
|
213
|
+
root, _, cur = locate_work_root()
|
|
214
|
+
return root.parent if root else cur
|
|
205
215
|
|
|
206
216
|
|
|
207
217
|
def main() -> int:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.75",
|
|
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/
|
|
60
|
+
"@jarvis/anthropic": "1.0.0",
|
|
61
61
|
"@jarvis/data": "0.1.0",
|
|
62
|
+
"@jarvis/board": "0.1.0",
|
|
62
63
|
"@jarvis/errors": "1.0.0",
|
|
64
|
+
"@jarvis/logger": "1.0.0",
|
|
63
65
|
"@jarvis/rpc": "1.0.0",
|
|
64
66
|
"@jarvis/types": "1.0.0",
|
|
65
|
-
"@jarvis/typescript-config": "1.0.0",
|
|
66
67
|
"@jarvis/ui": "0.1.0",
|
|
67
68
|
"@jarvis/vitest-config": "1.0.0",
|
|
68
|
-
"@jarvis/
|
|
69
|
-
"@jarvis/logger": "1.0.0"
|
|
69
|
+
"@jarvis/typescript-config": "1.0.0"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|
|
72
72
|
"dev": "tsx watch src/bin.ts start --foreground",
|