@appchy/jarvis 0.1.114 → 0.1.115
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 +20 -10
- package/dist/bin.js.map +1 -1
- package/harness/harness/links.py +114 -0
- package/harness/harness/report.py +44 -0
- package/harness/test_work.py +97 -0
- package/harness/work.py +8 -3
- package/package.json +5 -5
package/harness/harness/links.py
CHANGED
|
@@ -191,3 +191,117 @@ def repairing(repo: Path, indent: str = " "):
|
|
|
191
191
|
print(f"{indent}repaired {repaired} link(s) that pointed at what moved")
|
|
192
192
|
for line in unresolved:
|
|
193
193
|
print(f"{indent}COULD NOT PLACE {line}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
#: The three files that ARE a board item, so a folder holding one is addressable by
|
|
197
|
+
#: its name. Names are globally unique across tasks, epics and versions, which is what
|
|
198
|
+
#: makes a name a usable second resolver when a path has stopped working.
|
|
199
|
+
_ITEM_FILES = ("task.md", "epic.md", "version.md")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _index(base: Path) -> dict:
|
|
203
|
+
"""Board item folders by name. A name with two homes is left out rather than guessed."""
|
|
204
|
+
homes: dict = {}
|
|
205
|
+
for doc in base.rglob("*.md"):
|
|
206
|
+
if SKIP & set(doc.parts) or doc.name not in _ITEM_FILES:
|
|
207
|
+
continue
|
|
208
|
+
homes.setdefault(doc.parent.name, set()).add(doc.parent.resolve())
|
|
209
|
+
return {name: next(iter(dirs)) for name, dirs in homes.items() if len(dirs) == 1}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _settle(doc: Path, href: str, index: dict):
|
|
213
|
+
"""Where a broken link meant to point, or None when that is a guess.
|
|
214
|
+
|
|
215
|
+
Two resolvers, and both must land on exactly one file. **Depth first**: the tail
|
|
216
|
+
is right and the `../` count is wrong, which is what a file written at one tier
|
|
217
|
+
and moved to another leaves behind. **Then by name**: the item moved bucket or
|
|
218
|
+
epic, so no `../` count reaches it, but the folder it lives in is named in the
|
|
219
|
+
link and a board name has one home. Anything either resolver finds twice, or
|
|
220
|
+
neither finds at all, is reported for a person — a link repaired to the wrong
|
|
221
|
+
real file is worse than one that is visibly broken.
|
|
222
|
+
"""
|
|
223
|
+
path = href.split("#")[0]
|
|
224
|
+
# Only the leading `./` and `../` hops come off. `lstrip("./")` would take the
|
|
225
|
+
# dot of `.github` with them and then resolve nothing, which is a link reported
|
|
226
|
+
# as unfixable because the checker damaged it on the way in.
|
|
227
|
+
tail = _HOPS.sub("", path)
|
|
228
|
+
hits = {c.resolve() for n in range(10)
|
|
229
|
+
if (c := doc.parent / ("../" * n) / tail).exists()}
|
|
230
|
+
if len(hits) == 1:
|
|
231
|
+
return next(iter(hits))
|
|
232
|
+
|
|
233
|
+
parts = [p for p in path.split("/") if p not in ("..", ".", "")]
|
|
234
|
+
if len(parts) >= 2 and (home := index.get(parts[-2])) is not None:
|
|
235
|
+
want = home / parts[-1]
|
|
236
|
+
if want.exists():
|
|
237
|
+
return want.resolve()
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
#: An inline code span. A doc that TEACHES the link syntax writes `](../…md)` inside
|
|
242
|
+
#: backticks, and a checker that reads it as a link reports a file nobody ever meant
|
|
243
|
+
#: to exist. Same shape of suppression the governance lints use, and the same reason:
|
|
244
|
+
#: a blocking check's whole cost is its false positives.
|
|
245
|
+
#:
|
|
246
|
+
#: It crosses newlines because a quoted anchor wraps like any other prose, and the
|
|
247
|
+
#: one live false positive this check ever produced was a span that opened on one
|
|
248
|
+
#: line and closed on the next. Bounded rather than greedy: an unbalanced backtick
|
|
249
|
+
#: somewhere in a long document must not swallow the links after it.
|
|
250
|
+
CODE_SPAN = re.compile(r"`[^`]{0,500}`", re.S)
|
|
251
|
+
|
|
252
|
+
#: Leading `./` and `../` hops, and nothing else.
|
|
253
|
+
_HOPS = re.compile(r"^(?:\.{1,2}/)+")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def dangling(base: Path) -> list:
|
|
257
|
+
"""Every relative markdown link under `base` that resolves to nothing.
|
|
258
|
+
|
|
259
|
+
Returns `(doc, href, fix_or_None)` per broken link. This is the set `snapshot()`
|
|
260
|
+
records and `repair()` refuses on purpose — a link broken BEFORE a move has no
|
|
261
|
+
before-state to be repaired by identity, so it is settled from its own text or
|
|
262
|
+
not at all.
|
|
263
|
+
"""
|
|
264
|
+
index = _index(base)
|
|
265
|
+
out = []
|
|
266
|
+
for doc in sorted(_docs(base)):
|
|
267
|
+
try:
|
|
268
|
+
text = doc.read_text(errors="ignore")
|
|
269
|
+
except OSError:
|
|
270
|
+
continue
|
|
271
|
+
text = CODE_SPAN.sub("", text)
|
|
272
|
+
for href in dict.fromkeys(m.group(1) for m in LINK.finditer(text)):
|
|
273
|
+
if "://" in href or href.startswith(("#", "mailto:", "/")):
|
|
274
|
+
continue
|
|
275
|
+
if (doc.parent / href.split("#")[0]).exists():
|
|
276
|
+
continue
|
|
277
|
+
out.append((doc, href, _settle(doc, href, index)))
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def repair_dangling(base: Path, apply: bool = False) -> tuple:
|
|
282
|
+
"""Point every settled broken link at what it names. Returns (fixed, stuck).
|
|
283
|
+
|
|
284
|
+
Rewrites on the link's own syntax, never on the bare path, so a path that also
|
|
285
|
+
appears in a sentence is left alone — the same anchoring `repair()` uses, for the
|
|
286
|
+
same reason.
|
|
287
|
+
"""
|
|
288
|
+
fixed, stuck = 0, []
|
|
289
|
+
edits: dict = {}
|
|
290
|
+
for doc, href, target in dangling(base):
|
|
291
|
+
if target is None:
|
|
292
|
+
stuck.append((doc, href))
|
|
293
|
+
continue
|
|
294
|
+
_, _, anchor = href.partition("#")
|
|
295
|
+
new = os.path.relpath(target, doc.parent) + (f"#{anchor}" if anchor else "")
|
|
296
|
+
if new == href:
|
|
297
|
+
stuck.append((doc, href))
|
|
298
|
+
continue
|
|
299
|
+
edits.setdefault(doc, []).append((href, new))
|
|
300
|
+
fixed += 1
|
|
301
|
+
if apply:
|
|
302
|
+
for doc, pairs in edits.items():
|
|
303
|
+
text = doc.read_text(errors="ignore")
|
|
304
|
+
for href, new in pairs:
|
|
305
|
+
text = text.replace(f"]({href})", f"]({new})")
|
|
306
|
+
doc.write_text(text)
|
|
307
|
+
return fixed, stuck
|
|
@@ -165,6 +165,50 @@ def misalignments(root: Path) -> list:
|
|
|
165
165
|
+ _align_agents(root) + _align_retired(root) + _align_acceptance(root))
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
#: A cut that has shipped, and the archive it ends up in. Their links are reported
|
|
169
|
+
#: and never blocking: a plan naming source that has since been deleted, or a
|
|
170
|
+
#: `handoff.md` that `archive` stripped, is an accurate record of a world that is
|
|
171
|
+
#: gone. Demanding those resolve would make the gate unsatisfiable for a reason
|
|
172
|
+
#: nobody could act on.
|
|
173
|
+
_SHIPPED = ("versions/complete/", "archive/")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_links(args) -> int:
|
|
177
|
+
"""Board links that point at nothing. Exits 1 on a LIVE one, so a gate can block.
|
|
178
|
+
|
|
179
|
+
Blocking is safe here in a way it is not for the prose checks next door: a link
|
|
180
|
+
either resolves or it does not, there is no sentence to argue with, and the steady
|
|
181
|
+
state is zero because every folder move repairs its own links. A red therefore
|
|
182
|
+
means somebody hand-wrote a path, which is a ten-second fix — not a judgement call
|
|
183
|
+
a reader has to relitigate.
|
|
184
|
+
|
|
185
|
+
`--fix` repairs what is settled and leaves the rest. It is the only write, and it
|
|
186
|
+
is refused nothing: repairing a link that resolves to exactly one file cannot lose
|
|
187
|
+
anything, because the old path resolved to no file at all.
|
|
188
|
+
"""
|
|
189
|
+
from . import links as links_mod
|
|
190
|
+
root = find_work_root()
|
|
191
|
+
if args.get("fix"):
|
|
192
|
+
fixed, _ = links_mod.repair_dangling(root, apply=True)
|
|
193
|
+
print(f" repaired {fixed} link(s)")
|
|
194
|
+
|
|
195
|
+
broken = links_mod.dangling(root)
|
|
196
|
+
live = [(d, h) for d, h, _ in broken
|
|
197
|
+
if not any(m in str(d) for m in _SHIPPED)]
|
|
198
|
+
shipped = len(broken) - len(live)
|
|
199
|
+
|
|
200
|
+
for doc, href in live:
|
|
201
|
+
print(f" ✗ {doc.relative_to(root.parent)} -> {href}")
|
|
202
|
+
if shipped:
|
|
203
|
+
print(f" · {shipped} more in cuts that have shipped — reported, never blocking")
|
|
204
|
+
if not live:
|
|
205
|
+
print(" every link in a live brief or governance file resolves")
|
|
206
|
+
return 0
|
|
207
|
+
print(f"\n {len(live)} broken link(s) in live files · exit 1 "
|
|
208
|
+
f"(`jarvis work links --fix` repairs what it can settle)")
|
|
209
|
+
return 1
|
|
210
|
+
|
|
211
|
+
|
|
168
212
|
def cmd_align(args) -> int:
|
|
169
213
|
"""Report every misalignment class. REPORT-ONLY, exit 0 — by founder call.
|
|
170
214
|
|
package/harness/test_work.py
CHANGED
|
@@ -2076,6 +2076,103 @@ def test_a_finished_cut_leaves_the_board_without_pretending_it_shipped():
|
|
|
2076
2076
|
os.environ.pop("WORK_DIR", None)
|
|
2077
2077
|
|
|
2078
2078
|
|
|
2079
|
+
def test_a_standing_broken_link_is_settled_by_depth_or_by_board_name():
|
|
2080
|
+
# The set `repair()` refuses on purpose: broken BEFORE any move, so there is no
|
|
2081
|
+
# before-state and identity cannot answer. Two resolvers instead — the tail is
|
|
2082
|
+
# right and the `../` count is wrong, or the item moved bucket and the folder it
|
|
2083
|
+
# lives in is named in the link.
|
|
2084
|
+
from harness import links
|
|
2085
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2086
|
+
base = Path(tmp) / "work"
|
|
2087
|
+
(base / "product").mkdir(parents=True)
|
|
2088
|
+
(base / "product" / "board.md").write_text("# board\n")
|
|
2089
|
+
moved = base / "versions" / "a-cut" / "an-epic" / "complete" / "shipped-thing"
|
|
2090
|
+
moved.mkdir(parents=True)
|
|
2091
|
+
(moved / "task.md").write_text("# shipped thing\n")
|
|
2092
|
+
|
|
2093
|
+
doc = base / "versions" / "a-cut" / "an-epic" / "queue" / "live-one" / "task.md"
|
|
2094
|
+
doc.parent.mkdir(parents=True)
|
|
2095
|
+
doc.write_text(
|
|
2096
|
+
# two hops short of product/
|
|
2097
|
+
"governed by [board](../../../product/board.md), "
|
|
2098
|
+
# names a bucket the item has left
|
|
2099
|
+
"after [shipped](../shipped-thing/task.md).\n")
|
|
2100
|
+
|
|
2101
|
+
fixed, stuck = links.repair_dangling(base, apply=True)
|
|
2102
|
+
assert (fixed, stuck) == (2, []), (fixed, stuck)
|
|
2103
|
+
body = doc.read_text()
|
|
2104
|
+
assert "](../../../../../product/board.md)" in body, body
|
|
2105
|
+
assert "](../../complete/shipped-thing/task.md)" in body, body
|
|
2106
|
+
assert links.dangling(base) == []
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def test_a_broken_link_two_resolvers_disagree_about_is_left_for_a_person():
|
|
2110
|
+
# A link repaired to the wrong real file is worse than one that is visibly
|
|
2111
|
+
# broken: nothing afterwards can tell it was ever wrong.
|
|
2112
|
+
from harness import links
|
|
2113
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2114
|
+
base = Path(tmp) / "work"
|
|
2115
|
+
# The SAME tail exists at two heights on one walk up, so counting `../`
|
|
2116
|
+
# cannot choose between them.
|
|
2117
|
+
(base / "notes").mkdir(parents=True)
|
|
2118
|
+
(base / "notes" / "shared.md").write_text("# the outer one\n")
|
|
2119
|
+
(base / "one" / "notes").mkdir(parents=True)
|
|
2120
|
+
(base / "one" / "notes" / "shared.md").write_text("# the inner one\n")
|
|
2121
|
+
doc = base / "one" / "notes" / "deep" / "doc.md"
|
|
2122
|
+
doc.parent.mkdir(parents=True)
|
|
2123
|
+
doc.write_text("see [it](../../../../notes/shared.md)\n")
|
|
2124
|
+
|
|
2125
|
+
fixed, stuck = links.repair_dangling(base, apply=True)
|
|
2126
|
+
assert fixed == 0, "a link two candidates answer must not be rewritten"
|
|
2127
|
+
assert [h for _, h in stuck] == ["../../../../notes/shared.md"]
|
|
2128
|
+
assert "](../../../../notes/shared.md)" in doc.read_text(), "it was rewritten"
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
def test_a_link_inside_a_code_span_is_prose_and_never_a_finding():
|
|
2132
|
+
# A doc teaching the syntax writes `](../…md)` in backticks. The live false
|
|
2133
|
+
# positive this check ever produced was a quoted anchor whose span opened on one
|
|
2134
|
+
# line and closed on the next, so the span has to cross newlines.
|
|
2135
|
+
from harness import links
|
|
2136
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2137
|
+
base = Path(tmp) / "work"
|
|
2138
|
+
base.mkdir(parents=True)
|
|
2139
|
+
(base / "doc.md").write_text(
|
|
2140
|
+
"Resolving every `](../…md)` in the tree.\n\n"
|
|
2141
|
+
"The anchor reads `[sessions](sessions.md)'s AC-03 and the test\n"
|
|
2142
|
+
"lands later`, which is a quotation.\n\n"
|
|
2143
|
+
"But [this one](../really-gone.md) is a link.\n")
|
|
2144
|
+
|
|
2145
|
+
broken = links.dangling(base)
|
|
2146
|
+
assert [h for _, h, _ in broken] == ["../really-gone.md"], broken
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
def test_the_links_gate_blocks_on_a_live_brief_and_never_on_a_shipped_cut():
|
|
2150
|
+
# Blocking is only safe because the steady state is zero — every folder move
|
|
2151
|
+
# repairs its own links — and because a shipped cut is allowed to name things
|
|
2152
|
+
# that have since been deleted.
|
|
2153
|
+
import contextlib, io
|
|
2154
|
+
from harness import report
|
|
2155
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2156
|
+
root = Path(tmp) / "work"
|
|
2157
|
+
shipped = root / "versions" / "complete" / "01-done" / "an-epic"
|
|
2158
|
+
shipped.mkdir(parents=True)
|
|
2159
|
+
(shipped / "epic.md").write_text("gone: [src](../../../../src/deleted.ts)\n")
|
|
2160
|
+
os.environ["WORK_DIR"] = str(root)
|
|
2161
|
+
try:
|
|
2162
|
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
|
2163
|
+
assert report.cmd_links({}) == 0, "a shipped cut must not block"
|
|
2164
|
+
assert "never blocking" in out.getvalue()
|
|
2165
|
+
|
|
2166
|
+
live = root / "versions" / "02-open" / "an-epic" / "queue" / "a-task"
|
|
2167
|
+
live.mkdir(parents=True)
|
|
2168
|
+
(live / "task.md").write_text("see [rule](../../../../nowhere.md)\n")
|
|
2169
|
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
|
2170
|
+
assert report.cmd_links({}) == 1, "a live broken link must block"
|
|
2171
|
+
assert "nowhere.md" in out.getvalue()
|
|
2172
|
+
finally:
|
|
2173
|
+
os.environ.pop("WORK_DIR", None)
|
|
2174
|
+
|
|
2175
|
+
|
|
2079
2176
|
def test_links_are_repaired_by_what_they_pointed_at_not_by_what_they_say():
|
|
2080
2177
|
from harness import links
|
|
2081
2178
|
with tempfile.TemporaryDirectory() as tmp:
|
package/harness/work.py
CHANGED
|
@@ -77,6 +77,9 @@ Subcommands:
|
|
|
77
77
|
list --branch <ref> the board as another branch has it (read-only)
|
|
78
78
|
rules --task <name> what constrains a task, derived
|
|
79
79
|
align [--class <name>] every misalignment class; REPORT-ONLY, exit 0
|
|
80
|
+
links [--fix] board links that point at nothing. Exits 1 when a
|
|
81
|
+
LIVE brief or governance file holds one, so a gate
|
|
82
|
+
can block; history is reported and never blocks
|
|
80
83
|
wrap [--task <name>] finish a session cleanly: what a machine knows
|
|
81
84
|
about this run, then what to do. Writes NOTHING
|
|
82
85
|
coverage [--feature <name>] what a RUN proved vs what features promise
|
|
@@ -143,7 +146,7 @@ from harness import ids
|
|
|
143
146
|
from harness.architecture import (cmd_domain_new, cmd_init, cmd_rules, cmd_system_new,
|
|
144
147
|
cmd_where)
|
|
145
148
|
from harness.coverage import cmd_coverage
|
|
146
|
-
from harness.report import cmd_align, cmd_list, cmd_readme
|
|
149
|
+
from harness.report import cmd_align, cmd_links, cmd_list, cmd_readme
|
|
147
150
|
from harness.wrap import cmd_wrap
|
|
148
151
|
from harness.config import (DEFAULTS, ConfigError, apply, cmd_applies, cmd_config,
|
|
149
152
|
cmd_context, cmd_method, cmd_remind, resolve)
|
|
@@ -167,7 +170,7 @@ SUBCOMMANDS = (
|
|
|
167
170
|
"find", "list", "readme", "move", "plan", "session", "kickoff", "path", "code",
|
|
168
171
|
"domain-new", "system-new", "where", "rules", "align", "wrap", "coverage",
|
|
169
172
|
"migrate", "next", "status", "drop", "ask", "answer", "needs", "verify",
|
|
170
|
-
"observed", "log", "digest", "sync", "check",
|
|
173
|
+
"observed", "log", "digest", "sync", "check", "links",
|
|
171
174
|
)
|
|
172
175
|
|
|
173
176
|
|
|
@@ -350,7 +353,7 @@ def _say(note: str) -> None:
|
|
|
350
353
|
#: remembering to come back here.
|
|
351
354
|
_READS = {"list", "find", "path", "code", "where", "rules", "status", "digest",
|
|
352
355
|
"log", "needs", "align", "coverage", "readme", "config", "context",
|
|
353
|
-
"doctor", "kickoff", "remind", "applies", "wrap"}
|
|
356
|
+
"doctor", "kickoff", "remind", "applies", "wrap", "links"}
|
|
354
357
|
|
|
355
358
|
|
|
356
359
|
def _reads_only(cmd: str, flags: dict) -> None:
|
|
@@ -514,6 +517,8 @@ def dispatch(cmd, pos, flags, cfg) -> int:
|
|
|
514
517
|
return cmd_rules({**flags, "task": flags.get("task") or (pos[0] if pos else None)})
|
|
515
518
|
if cmd == "align":
|
|
516
519
|
return cmd_align(flags)
|
|
520
|
+
if cmd == "links":
|
|
521
|
+
return cmd_links(flags)
|
|
517
522
|
if cmd == "wrap":
|
|
518
523
|
return cmd_wrap(cfg, flags, _project_root(flags))
|
|
519
524
|
if cmd == "coverage":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.115",
|
|
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
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/rpc": "1.0.0",
|
|
65
|
-
"@jarvis/anthropic": "1.0.0",
|
|
66
65
|
"@jarvis/types": "1.0.0",
|
|
66
|
+
"@jarvis/rpc": "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",
|