@appchy/jarvis 0.1.116 → 0.1.117

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 CHANGED
@@ -10284,7 +10284,7 @@ import { createRequire as createRequire2 } from "module";
10284
10284
  var _require = createRequire2(import.meta.url);
10285
10285
  var VERSION2 = _require("../package.json").version ?? "0.0.0";
10286
10286
  var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
10287
- var SHA = "6a8647e";
10287
+ var SHA = "d379214";
10288
10288
  var BUILT = "2026-09-12";
10289
10289
  var BUILD = SHA ?? "source";
10290
10290
  var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
@@ -199,13 +199,22 @@ def repairing(repo: Path, indent: str = " "):
199
199
  _ITEM_FILES = ("task.md", "epic.md", "version.md")
200
200
 
201
201
 
202
- def _index(base: Path) -> dict:
202
+ def _prose(text: str) -> str:
203
+ """`text` with inline code spans blanked, offsets preserved.
204
+
205
+ Blanked rather than removed so a position found here is the same position in the
206
+ real text — which is what lets the WRITE path edit exactly the links the READ path
207
+ found, and nothing else.
208
+ """
209
+ return CODE_SPAN.sub(lambda m: " " * len(m.group(0)), text)
210
+
211
+
212
+ def _index(docs) -> dict:
203
213
  """Board item folders by name. A name with two homes is left out rather than guessed."""
204
214
  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())
215
+ for doc in docs:
216
+ if doc.name in _ITEM_FILES:
217
+ homes.setdefault(doc.parent.name, set()).add(doc.parent.resolve())
209
218
  return {name: next(iter(dirs)) for name, dirs in homes.items() if len(dirs) == 1}
210
219
 
211
220
 
@@ -261,15 +270,15 @@ def dangling(base: Path) -> list:
261
270
  before-state to be repaired by identity, so it is settled from its own text or
262
271
  not at all.
263
272
  """
264
- index = _index(base)
273
+ docs = sorted(_docs(base))
274
+ index = _index(docs)
265
275
  out = []
266
- for doc in sorted(_docs(base)):
276
+ for doc in docs:
267
277
  try:
268
278
  text = doc.read_text(errors="ignore")
269
279
  except OSError:
270
280
  continue
271
- text = CODE_SPAN.sub("", text)
272
- for href in dict.fromkeys(m.group(1) for m in LINK.finditer(text)):
281
+ for href in dict.fromkeys(m.group(1) for m in LINK.finditer(_prose(text))):
273
282
  if "://" in href or href.startswith(("#", "mailto:", "/")):
274
283
  continue
275
284
  if (doc.parent / href.split("#")[0]).exists():
@@ -281,9 +290,12 @@ def dangling(base: Path) -> list:
281
290
  def repair_dangling(base: Path, apply: bool = False) -> tuple:
282
291
  """Point every settled broken link at what it names. Returns (fixed, stuck).
283
292
 
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.
293
+ **Edits by position, never by `str.replace`.** The same href can appear twice in
294
+ one file once as the broken link and once inside a code span quoting it, which
295
+ is exactly what a brief warning about the mistake looks like. A blanket replace
296
+ rewrites the quotation too, so the document that documents the error silently
297
+ stops showing it. Only the positions the read path found, which is prose, are
298
+ touched.
287
299
  """
288
300
  fixed, stuck = 0, []
289
301
  edits: dict = {}
@@ -296,12 +308,19 @@ def repair_dangling(base: Path, apply: bool = False) -> tuple:
296
308
  if new == href:
297
309
  stuck.append((doc, href))
298
310
  continue
299
- edits.setdefault(doc, []).append((href, new))
311
+ edits.setdefault(doc, {})[href] = new
300
312
  fixed += 1
301
313
  if apply:
302
- for doc, pairs in edits.items():
314
+ for doc, mapping in edits.items():
303
315
  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)
316
+ pieces, last = [], 0
317
+ for m in LINK.finditer(_prose(text)):
318
+ replacement = mapping.get(m.group(1))
319
+ if replacement is None:
320
+ continue
321
+ pieces.append(text[last:m.start(1)])
322
+ pieces.append(replacement)
323
+ last = m.end(1)
324
+ pieces.append(text[last:])
325
+ doc.write_text("".join(pieces))
307
326
  return fixed, stuck
@@ -170,6 +170,14 @@ def misalignments(root: Path) -> list:
170
170
  #: `handoff.md` that `archive` stripped, is an accurate record of a world that is
171
171
  #: gone. Demanding those resolve would make the gate unsatisfiable for a reason
172
172
  #: nobody could act on.
173
+ #:
174
+ #: **`--fix` deliberately does not honour this, and the asymmetry is the point.** The
175
+ #: exemption says a shipped cut cannot be REQUIRED to resolve, not that it should be
176
+ #: left broken where it can be mended. A repair only ever moves a link onto the file
177
+ #: it already named — `repair_dangling` refuses anything two candidates answer — so a
178
+ #: shipped plan still points at the same document, which is what `links.repairing`
179
+ #: does to archived docs on every folder move anyway. What survives untouched is the
180
+ #: set that cannot be settled, which is where the record actually lives.
173
181
  _SHIPPED = ("versions/complete/", "archive/")
174
182
 
175
183
 
@@ -2106,6 +2106,30 @@ def test_a_standing_broken_link_is_settled_by_depth_or_by_board_name():
2106
2106
  assert links.dangling(base) == []
2107
2107
 
2108
2108
 
2109
+ def test_repairing_a_link_never_touches_the_same_href_quoted_in_prose():
2110
+ # A brief warning about a mistake quotes the wrong path in backticks AND may hold
2111
+ # the real broken link further down. `str.replace` over the raw text rewrites the
2112
+ # quotation too, so the document that documents the error silently stops showing
2113
+ # it — the read path stripped code spans and the write path did not. Reproduced
2114
+ # before it was fixed.
2115
+ from harness import links
2116
+ with tempfile.TemporaryDirectory() as tmp:
2117
+ base = Path(tmp) / "work"
2118
+ (base / "product").mkdir(parents=True)
2119
+ (base / "product" / "board.md").write_text("# board\n")
2120
+ doc = base / "notes" / "deep" / "doc.md"
2121
+ doc.parent.mkdir(parents=True)
2122
+ doc.write_text(
2123
+ "Getting the depth wrong looks like `[board](../product/board.md)`.\n\n"
2124
+ "A real one: [board](../product/board.md).\n")
2125
+
2126
+ fixed, stuck = links.repair_dangling(base, apply=True)
2127
+ body = doc.read_text()
2128
+ assert (fixed, stuck) == (1, [])
2129
+ assert "`[board](../product/board.md)`" in body, "the quotation was rewritten"
2130
+ assert "A real one: [board](../../product/board.md)." in body, body
2131
+
2132
+
2109
2133
  def test_a_broken_link_two_resolvers_disagree_about_is_left_for_a_person():
2110
2134
  # A link repaired to the wrong real file is worse than one that is visibly
2111
2135
  # broken: nothing afterwards can tell it was ever wrong.
@@ -4984,6 +5008,19 @@ def test_a_write_aimed_at_another_branch_is_refused():
4984
5008
  raise AssertionError("a write with --branch must be refused")
4985
5009
 
4986
5010
 
5011
+ def test_links_is_refused_a_branch_because_fix_writes_this_one():
5012
+ # It reports far more often than it fixes, which is what made listing it as a read
5013
+ # look right. `--fix` writes, and it writes to the tree that is CHECKED OUT — so
5014
+ # being listed would have let `links --fix --branch other` past this gate and
5015
+ # edited the wrong branch while the caller believed otherwise.
5016
+ try:
5017
+ entry._reads_only("links", {"branch": "theirs"})
5018
+ except SystemExit as e:
5019
+ assert e.code != 0
5020
+ else:
5021
+ raise AssertionError("`links` must not accept --branch: --fix writes")
5022
+
5023
+
4987
5024
  def test_a_read_may_name_a_branch():
4988
5025
  # The same gate must not refuse the commands the flag exists for.
4989
5026
  entry._reads_only("list", {"branch": "theirs"})
package/harness/work.py CHANGED
@@ -351,9 +351,15 @@ def _say(note: str) -> None:
351
351
  #: Commands that only LOOK. `--branch` is meaningful on these and refused on every
352
352
  #: other, which is the safe default: a command added later is covered without anyone
353
353
  #: remembering to come back here.
354
+ #:
355
+ #: `links` is deliberately NOT here even though it usually only reports. `--fix`
356
+ #: writes, and it writes to the tree that is checked out — so listing it would let
357
+ #: `links --fix --branch other` past the refusal and edit this branch while the caller
358
+ #: believed they were touching another. It reads the working tree in both modes, so
359
+ #: `--branch` could never have meant anything for it.
354
360
  _READS = {"list", "find", "path", "code", "where", "rules", "status", "digest",
355
361
  "log", "needs", "align", "coverage", "readme", "config", "context",
356
- "doctor", "kickoff", "remind", "applies", "wrap", "links"}
362
+ "doctor", "kickoff", "remind", "applies", "wrap"}
357
363
 
358
364
 
359
365
  def _reads_only(cmd: str, flags: dict) -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.116",
3
+ "version": "0.1.117",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -56,14 +56,14 @@
56
56
  "tsup": "^8.5.1",
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^2.1.0",
59
- "@jarvis/agents": "1.0.0",
60
- "@jarvis/anthropic": "1.0.0",
61
59
  "@jarvis/board": "0.1.0",
62
60
  "@jarvis/data": "0.1.0",
61
+ "@jarvis/errors": "1.0.0",
63
62
  "@jarvis/logger": "1.0.0",
64
63
  "@jarvis/rpc": "1.0.0",
65
- "@jarvis/errors": "1.0.0",
66
64
  "@jarvis/types": "1.0.0",
65
+ "@jarvis/agents": "1.0.0",
66
+ "@jarvis/anthropic": "1.0.0",
67
67
  "@jarvis/typescript-config": "1.0.0",
68
68
  "@jarvis/ui": "0.1.0",
69
69
  "@jarvis/vitest-config": "1.0.0"