@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.
@@ -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/` moves inside `versions/`, and `versions/archive/` is made.
70
+ """`work/backlog/` and the archive both move inside `versions/`.
56
71
 
57
- Both halves of one layout: backlog and archive are states a cut's work is in,
58
- so they belong beside the cuts rather than in a directory of their own.
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 one hard refusal. Both present means somebody has already started this,
69
- # or a repo has grown a second backlog — and merging two trees is a judgement
70
- # about which copy of a task is real, which is not a call a migration makes.
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
@@ -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
- return Path.cwd().resolve()
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.73",
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/board": "0.1.0",
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/anthropic": "1.0.0",
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",