@appchy/jarvis 0.1.71 → 0.1.73

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
@@ -10109,7 +10109,7 @@ import { createRequire as createRequire2 } from "module";
10109
10109
  var _require = createRequire2(import.meta.url);
10110
10110
  var VERSION2 = _require("../package.json").version ?? "0.0.0";
10111
10111
  var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
10112
- var SHA = "bf811fb";
10112
+ var SHA = "9e5b550";
10113
10113
  var BUILT = "2026-09-10";
10114
10114
  var BUILD = SHA ?? "source";
10115
10115
  var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
@@ -30,7 +30,7 @@ from .tree import (BLOCKED, BUCKETS, DEFAULT_AUTONOMY_CEILING, TIER3_OWNERS,
30
30
  from .frontmatter import as_list, parse_frontmatter, rewrite_file
31
31
  from .model import locate, record_session, scan
32
32
  from .generate import _sync
33
- from . import events
33
+ from . import events, links
34
34
 
35
35
  #: `autonomy.ceiling` from config — the highest tier an unattended run acts on
36
36
  #: alone. Set by `config.apply`, so a repo's answer is bound before any command
@@ -160,7 +160,9 @@ def cmd_ask(args) -> int:
160
160
  if dest.exists():
161
161
  die(f"{rel(dest, root)} already exists")
162
162
  dest.parent.mkdir(parents=True, exist_ok=True)
163
- shutil.move(str(task.folder), str(dest))
163
+ with links.repairing(root.parent) as moved:
164
+ moved[task.folder.resolve()] = dest.resolve()
165
+ shutil.move(str(task.folder), str(dest))
164
166
 
165
167
  md = dest / "task.md"
166
168
 
@@ -241,7 +243,9 @@ def cmd_answer(args) -> int:
241
243
  if dest.exists():
242
244
  die(f"{rel(dest, root)} already exists")
243
245
  dest.parent.mkdir(parents=True, exist_ok=True)
244
- shutil.move(str(task.folder), str(dest))
246
+ with links.repairing(root.parent, indent=" ") as moved:
247
+ moved[task.folder.resolve()] = dest.resolve()
248
+ shutil.move(str(task.folder), str(dest))
245
249
  events.append(root, "moved", name, **{"from": BLOCKED, "to": to})
246
250
  print(f"answered '{name}' -> {to}/")
247
251
  else:
@@ -6,7 +6,7 @@ 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
8
8
  from .generate import _sync
9
- from . import events
9
+ from . import events, links
10
10
 
11
11
 
12
12
  def cmd_epic_new(args) -> int:
@@ -98,11 +98,21 @@ def _epic_to_backlog(root, name: str) -> int:
98
98
  was = epic_home(root, epic)
99
99
  moved = epic.all_tasks()
100
100
  dest.parent.mkdir(parents=True, exist_ok=True)
101
- shutil.move(str(epic.folder), str(dest))
102
- for t in moved:
103
- bucketed = dest / t.status / t.name
104
- if bucketed.exists():
105
- shutil.move(str(bucketed), str(dest / t.name))
101
+ # One snapshot for the epic AND every task lifted out of its bucket beneath it.
102
+ # Per-move would rescan the whole repo once per task, and a backlog epic can
103
+ # carry thirty.
104
+ with links.repairing(root.parent) as relocated:
105
+ # Each task keyed on where it was BEFORE the epic moved, because that is
106
+ # the path every link naming it used — and it lands loose under the new
107
+ # epic rather than at the bucketed path the epic move alone would imply.
108
+ for t in moved:
109
+ relocated[t.folder.resolve()] = (dest / t.name).resolve()
110
+ relocated[epic.folder.resolve()] = dest.resolve()
111
+ shutil.move(str(epic.folder), str(dest))
112
+ for t in moved:
113
+ bucketed = dest / t.status / t.name
114
+ if bucketed.exists():
115
+ shutil.move(str(bucketed), str(dest / t.name))
106
116
  for bucket in BUCKETS:
107
117
  empty = dest / bucket
108
118
  if empty.is_dir() and not any(empty.iterdir()):
@@ -166,14 +176,21 @@ def cmd_epic_move(args) -> int:
166
176
  die(f"{rel(dest, root)} already exists")
167
177
  was = epic_home(root, epic)
168
178
  moved = epic.all_tasks()
169
- shutil.move(str(epic.folder), str(dest))
170
-
171
- # A backlog epic's tasks were loose under it; give them the queue bucket.
172
- if epic.in_backlog:
173
- queue = dest / "queue"
174
- queue.mkdir(exist_ok=True)
175
- for t in moved:
176
- shutil.move(str(dest / t.name), str(queue / t.name))
179
+ with links.repairing(root.parent) as relocated:
180
+ # A backlog epic's tasks were loose under it and gain the queue bucket, so
181
+ # they end one level deeper than the epic move alone would put them. Keyed
182
+ # on where each was before any of this, which is what links name.
183
+ if epic.in_backlog:
184
+ for t in moved:
185
+ relocated[t.folder.resolve()] = (dest / "queue" / t.name).resolve()
186
+ relocated[epic.folder.resolve()] = dest.resolve()
187
+ shutil.move(str(epic.folder), str(dest))
188
+
189
+ if epic.in_backlog:
190
+ queue = dest / "queue"
191
+ queue.mkdir(exist_ok=True)
192
+ for t in moved:
193
+ shutil.move(str(dest / t.name), str(queue / t.name))
177
194
 
178
195
  md = dest / "epic.md"
179
196
  rewrite_file(
@@ -1,7 +1,7 @@
1
1
  import os
2
2
  import re
3
3
 
4
- from . import ids
4
+ from . import ids, links
5
5
  import shutil
6
6
  from pathlib import Path
7
7
 
@@ -235,10 +235,12 @@ def settle_epic_tier(root: Path, include_released: bool = False) -> list:
235
235
 
236
236
  Released versions are skipped: they are a record, not a board, and `release`
237
237
  flattens the tier before stamping so an archived cut keeps a flat epic list."""
238
- moved = []
239
- for v in scan(root)["versions"]:
240
- if v.released and not include_released:
241
- continue
238
+ # Decided across every version BEFORE anything moves. This runs on each board
239
+ # mutation and settles nothing on almost all of them, so the link snapshot has
240
+ # to be paid for only when there is actually a move to repair.
241
+ versions = [v for v in scan(root)["versions"] if include_released or not v.released]
242
+ pending = []
243
+ for v in versions:
242
244
  for e in v.epics:
243
245
  done = e.is_done()
244
246
  if done == e.done_tier:
@@ -246,9 +248,18 @@ def settle_epic_tier(root: Path, include_released: bool = False) -> list:
246
248
  dest = (v.folder / DONE_TIER / e.name) if done else (v.folder / e.name)
247
249
  if dest.exists():
248
250
  continue
249
- dest.parent.mkdir(parents=True, exist_ok=True)
250
- shutil.move(str(e.folder), str(dest))
251
- moved.append((e.name, "complete" if done else "in flight"))
251
+ pending.append((e, dest, done))
252
+
253
+ moved = []
254
+ if pending:
255
+ with links.repairing(root.parent) as relocated:
256
+ for e, dest, done in pending:
257
+ dest.parent.mkdir(parents=True, exist_ok=True)
258
+ relocated[e.folder.resolve()] = dest.resolve()
259
+ shutil.move(str(e.folder), str(dest))
260
+ moved.append((e.name, "complete" if done else "in flight"))
261
+
262
+ for v in versions:
252
263
  # Leave no empty `complete/` behind — an empty tier reads as a claim that
253
264
  # something finished here.
254
265
  tier = v.folder / DONE_TIER
@@ -19,6 +19,7 @@ what is already broken, which is exactly the set that excludes the case above.
19
19
 
20
20
  import os
21
21
  import re
22
+ from contextlib import contextmanager
22
23
  from pathlib import Path
23
24
 
24
25
  #: Markdown inline links. Reference-style links and bare paths in prose are out of
@@ -68,8 +69,16 @@ def snapshot(repo: Path) -> dict:
68
69
 
69
70
 
70
71
  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():
72
+ """Where `path` is now, given what moved. Children follow their parent.
73
+
74
+ Deepest match wins, so the order a caller recorded its moves in cannot change
75
+ the answer. An epic moving with its tasks lifted out of their buckets records
76
+ both the epic and each task, and each task's own entry has to beat its epic's
77
+ — insertion order happened to give that and would have stopped the first time
78
+ somebody recorded the moves as they made them.
79
+ """
80
+ for old in sorted(moves, key=lambda p: len(p.parts), reverse=True):
81
+ new = moves[old]
73
82
  if path == old:
74
83
  return new
75
84
  try:
@@ -141,3 +150,44 @@ def stale_mentions(repo: Path, old_name: str, moved_into: Path) -> list:
141
150
  except OSError:
142
151
  continue
143
152
  return hits
153
+
154
+
155
+ @contextmanager
156
+ def repairing(repo: Path, indent: str = " "):
157
+ """Snapshot once, collect `old -> new` as the body moves things, repair on exit.
158
+
159
+ The snapshot/move/repair dance was pasted into each mover that had grown one,
160
+ which is exactly how the other six went without: three of nine folder moves
161
+ repaired their links and the rest broke them silently, in the same way, for
162
+ the same reason. A helper nobody has to remember is the only version of this
163
+ that stays true — and it makes `shutil.move` inside the harness the smell
164
+ rather than the default.
165
+
166
+ ONE snapshot per block, not per move. A mover that moves a task at a time
167
+ would otherwise rescan the whole repo once per task, and two of them do
168
+ exactly that: ~1s over 351 markdown files here, and 1,186 on a sibling.
169
+
170
+ Enter the block only when something is actually going to move — the snapshot
171
+ is taken on the way in, so a mover that decides against moving pays for a
172
+ scan it did not need. Collect the pairs first, then open the block.
173
+
174
+ **Key every entry on where the thing was BEFORE the block, not on where the
175
+ previous line just put it.** Links pointed at the original path and that is
176
+ the only path there is anything to look up. A two-stage move — an epic
177
+ relocated, then a task lifted out of a bucket underneath it — records the
178
+ task's ORIGINAL folder against its final home, not the half-way one.
179
+
180
+ The repair runs even when the body raises, because `die()` is an exception
181
+ and whatever it left behind has already moved.
182
+ """
183
+ before = snapshot(repo)
184
+ moved: dict = {}
185
+ try:
186
+ yield moved
187
+ finally:
188
+ if moved:
189
+ repaired, unresolved = repair(before, moved)
190
+ if repaired:
191
+ print(f"{indent}repaired {repaired} link(s) that pointed at what moved")
192
+ for line in unresolved:
193
+ print(f"{indent}COULD NOT PLACE {line}")
@@ -0,0 +1,129 @@
1
+ """Bring a repo's `work/` tree up to the layout this harness expects.
2
+
3
+ Every repo on this harness has its own copy of the tree, and a layout change
4
+ therefore has to happen once per repo, by hand, on somebody's afternoon. The last
5
+ one was carried out by a script written into a session's scratchpad — which is to
6
+ say it was carried out by a script that no longer exists, and the fifth repo to
7
+ need it would have had it written a second time. That is the shape this command
8
+ replaces: the migration ships with the harness that requires it.
9
+
10
+ **Idempotent, and safe to run on a repo that is already current** — it reports
11
+ what it found and changes nothing. That matters more than it sounds: nobody
12
+ remembers which repos have been through it, and a command you have to check
13
+ before running is a command somebody runs twice anyway.
14
+
15
+ **It repairs links, which is the whole reason it is here rather than in a shell
16
+ one-liner.** Moving `backlog/` moves every task under it, and the pointers at
17
+ those tasks live all over a repo — in other versions, under `complete/`, and
18
+ outside `work/` entirely. One repo's migration repaired ~450 of them across
19
+ `work/`, `projects/`, `reviews/`, `apps/` and `research/`. A migration scoped to
20
+ `work/` passes clean and silently rots the rest.
21
+ """
22
+
23
+ import shutil
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ from .tree import archive_dir, backlog_dir, die, find_work_root, rel
28
+ from . import links
29
+
30
+
31
+ def _uncommitted(repo: Path, work: Path) -> list:
32
+ """Paths under `work/` with uncommitted changes.
33
+
34
+ A layout migration moves folders another session may have open, and the repo
35
+ that most needs this command is the one with five sessions live in it. Git is
36
+ the only thing that knows, and a repo that is not a git checkout answers with
37
+ an empty list rather than blocking the migration.
38
+ """
39
+ try:
40
+ out = subprocess.run(["git", "status", "--porcelain"], cwd=repo,
41
+ capture_output=True, text=True, check=True).stdout
42
+ except Exception:
43
+ return []
44
+ dirty = []
45
+ for line in out.splitlines():
46
+ if len(line) <= 3:
47
+ continue
48
+ p = (repo / line[3:].strip().strip('"')).resolve()
49
+ if work.resolve() == p or work.resolve() in p.parents:
50
+ dirty.append(str(p.relative_to(repo)))
51
+ return sorted(dirty)
52
+
53
+
54
+ def cmd_migrate(args) -> int:
55
+ """`work/backlog/` moves inside `versions/`, and `versions/archive/` is made.
56
+
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.
59
+ """
60
+ root = find_work_root()
61
+ repo = root.parent
62
+ dry = bool(args.get("dry_run") or args.get("dry-run"))
63
+
64
+ old = root / "backlog"
65
+ new = backlog_dir(root)
66
+ archive = archive_dir(root)
67
+
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.
71
+ if old.is_dir() and new.is_dir():
72
+ die(f"both {rel(old, root)} and {rel(new, root)} exist — this repo has two "
73
+ f"backlogs and only a person can say which task is the real one. "
74
+ f"Merge them by hand, then run this again.")
75
+
76
+ todo = []
77
+ if old.is_dir():
78
+ todo.append("the backlog moves inside versions/")
79
+ if not archive.is_dir():
80
+ todo.append("versions/archive/ is created")
81
+ if not todo:
82
+ print(f"{rel(root, root)} is already on the current layout — nothing to do")
83
+ return 0
84
+
85
+ if dry:
86
+ print(f"would migrate {rel(root, root)}:")
87
+ for line in todo:
88
+ print(f" {line}")
89
+ if old.is_dir():
90
+ print(f" {sum(1 for _ in old.rglob('task.md'))} task(s) would move")
91
+ return 0
92
+
93
+ dirty = _uncommitted(repo, root)
94
+ if dirty and not args.get("force"):
95
+ die(f"{len(dirty)} uncommitted change(s) under {rel(root, root)} — a layout "
96
+ f"migration moves folders somebody may have open, and git is the only "
97
+ f"way back. Commit or stash first, then run this again (--force "
98
+ f"overrides).\n " + "\n ".join(dirty[:10]))
99
+
100
+ if old.is_dir():
101
+ moved_tasks = sum(1 for _ in old.rglob("task.md"))
102
+ new.parent.mkdir(parents=True, exist_ok=True)
103
+ with links.repairing(repo) as moved:
104
+ moved[old.resolve()] = new.resolve()
105
+ shutil.move(str(old), str(new))
106
+ print(f"moved {rel(old, root)} -> {rel(new, root)} ({moved_tasks} task(s))")
107
+
108
+ # Reported, never rewritten. Whether a sentence naming the old path is
109
+ # stale or a deliberate record of what was true then is a question only a
110
+ # person can answer — and this is the half no link check ever sees.
111
+ stale = links.stale_mentions(repo, "work/backlog", new)
112
+ if stale:
113
+ print(f" {len(stale)} line(s) still name `work/backlog` in prose — "
114
+ f"read them, they are not all wrong:")
115
+ for hit in stale[:10]:
116
+ print(f" {hit}")
117
+ if len(stale) > 10:
118
+ print(f" … and {len(stale) - 10} more")
119
+
120
+ if not archive.is_dir():
121
+ archive.mkdir(parents=True, exist_ok=True)
122
+ # An empty directory does not survive git, and the point of making it now
123
+ # is that `archive` has somewhere to put a cut without inventing it later.
124
+ (archive / ".gitkeep").write_text("")
125
+ print(f"created {rel(archive, root)}")
126
+
127
+ print("done — commit this, then every machine working this repo needs a CLI "
128
+ "new enough to read it")
129
+ return 0
@@ -27,7 +27,7 @@ from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
27
27
  from .frontmatter import rewrite_file
28
28
  from .model import locate, record_session, scan
29
29
  from .generate import _sync
30
- from . import autonomy, events, peers
30
+ from . import autonomy, events, links, peers
31
31
  # The ceiling is read through the MODULE, never bound in with `from … import`.
32
32
  # A `from .autonomy import CEILING` captures the value at import time, so
33
33
  # `config.apply` setting it afterwards would leave this file quietly running on
@@ -178,7 +178,9 @@ def cmd_next(args) -> int:
178
178
  if dest.exists():
179
179
  die(f"{rel(dest, root)} already exists")
180
180
  dest.parent.mkdir(parents=True, exist_ok=True)
181
- shutil.move(str(task.folder), str(dest))
181
+ with links.repairing(root.parent) as moved:
182
+ moved[task.folder.resolve()] = dest.resolve()
183
+ shutil.move(str(task.folder), str(dest))
182
184
  md = dest / "task.md"
183
185
  rewrite_file(
184
186
  md,
@@ -190,16 +190,9 @@ def cmd_place(args) -> int:
190
190
  # What every link in the repo pointed at, read BEFORE the move — because after
191
191
  # it there is nothing left to compare against, and a link that still resolves
192
192
  # to the wrong file is indistinguishable from one that is right.
193
- repo = root.parent
194
- before = links.snapshot(repo)
195
-
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}")
193
+ with links.repairing(root.parent) as moved:
194
+ moved[task.folder.resolve()] = dest.resolve()
195
+ shutil.move(str(task.folder), str(dest))
203
196
 
204
197
  md = dest / "task.md"
205
198
  rewrite_file(
@@ -264,7 +257,9 @@ def _to_backlog(root, name: str, args) -> int:
264
257
  if dest.exists():
265
258
  die(f"{rel(dest, root)} already exists")
266
259
  dest.parent.mkdir(parents=True, exist_ok=True)
267
- shutil.move(str(task.folder), str(dest))
260
+ with links.repairing(root.parent) as moved:
261
+ moved[task.folder.resolve()] = dest.resolve()
262
+ shutil.move(str(task.folder), str(dest))
268
263
  md = dest / "task.md"
269
264
  rewrite_file(
270
265
  md,
@@ -334,7 +329,14 @@ def cmd_move(args) -> int:
334
329
  if dest.exists():
335
330
  die(f"{rel(dest, root)} already exists")
336
331
  dest.parent.mkdir(parents=True, exist_ok=True)
337
- shutil.move(str(task.folder), str(dest))
332
+
333
+ # The bucket is part of every path that names this task, so a status change
334
+ # moves the folder and breaks whatever pointed at it — and `move` fires on every
335
+ # pickup and every completion, far more often than `place`. One audited board
336
+ # had 16 of its 37 broken links from a bucket change alone.
337
+ with links.repairing(root.parent) as moved:
338
+ moved[task.folder.resolve()] = dest.resolve()
339
+ shutil.move(str(task.folder), str(dest))
338
340
 
339
341
  def mutate(d):
340
342
  d["updated"] = date.today().isoformat()
@@ -105,7 +105,7 @@ def cmd_version_new(args) -> int:
105
105
  print(f" {rel(folder / 'architecture.md', root)} (the cross-task technical brief)")
106
106
  _sync(root)
107
107
  return 0
108
- def _flatten_done_tier(version) -> None:
108
+ def _flatten_done_tier(version, root) -> None:
109
109
  """Lift every epic out of `<v>/complete/` back to the version's top level.
110
110
 
111
111
  Called at release only. The tier exists to keep a moving board readable; once a
@@ -123,13 +123,22 @@ def _flatten_done_tier(version) -> None:
123
123
  tier = version.folder / DONE_TIER
124
124
  if not tier.is_dir():
125
125
  return
126
+ # Decided in full before anything moves, so the link snapshot is taken once and
127
+ # only when there is something to repair — and so a name collision refuses with
128
+ # the tier still intact rather than half-lifted.
129
+ pairs = []
126
130
  for epic in sorted(tier.iterdir()):
127
131
  if not _is_epic_dir(epic):
128
132
  continue
129
133
  dest = version.folder / epic.name
130
134
  if dest.exists():
131
135
  die(f"cannot flatten '{epic.name}' — {rel(dest, version.folder)} already exists")
132
- shutil.move(str(epic), str(dest))
136
+ pairs.append((epic, dest))
137
+ if pairs:
138
+ with links.repairing(root.parent, indent=" ") as moved:
139
+ for epic, dest in pairs:
140
+ moved[epic.resolve()] = dest.resolve()
141
+ shutil.move(str(epic), str(dest))
133
142
  if not any(tier.iterdir()):
134
143
  tier.rmdir()
135
144
 
@@ -157,7 +166,7 @@ def cmd_release(args) -> int:
157
166
  # definition, so leaving them parked would make `<v>/complete/` the whole cut —
158
167
  # a board affordance turned into the archived record's shape, for no reader. The
159
168
  # record keeps the flat epic list it has always had.
160
- _flatten_done_tier(version)
169
+ _flatten_done_tier(version, root)
161
170
  # THE FLATTEN MOVED FOLDERS, so every path hanging off `version` is now stale.
162
171
  # Re-locate before anything else touches the tree.
163
172
  #
@@ -304,14 +313,9 @@ def cmd_archive(args) -> int:
304
313
  if dest.exists():
305
314
  die(f"{rel(dest, root)} already exists")
306
315
  dest.parent.mkdir(parents=True, exist_ok=True)
307
- before = links.snapshot(root.parent)
308
- moved_from = version.folder.resolve()
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}")
316
+ with links.repairing(root.parent, indent=" ") as moved:
317
+ moved[version.folder.resolve()] = dest.resolve()
318
+ shutil.move(str(_contained(version.folder, root)), str(dest))
315
319
 
316
320
  where = f" — full docs in git history @ {sha}" if sha else ""
317
321
  events.append(root, "archived", name, stripped=removed)
@@ -6,6 +6,7 @@ three-tier scan, the version gate, and the four shape lints."""
6
6
  import importlib.util
7
7
  import json
8
8
  import os
9
+ import shutil
9
10
  import time
10
11
  import re
11
12
  import sys
@@ -13,6 +14,14 @@ import tempfile
13
14
  from datetime import date, datetime, timedelta, timezone
14
15
  from pathlib import Path
15
16
 
17
+ # Every fixture's work root is a `TemporaryDirectory`, and link repair scans the
18
+ # repo AROUND that root — its parent. Left on the system default that parent is the
19
+ # shared temp directory, which on a developer's machine held 209,000 entries and was
20
+ # walked once per move. Rooting the suite's own temp dir here makes that parent a
21
+ # directory containing only what this run put there.
22
+ _TMP = tempfile.mkdtemp(prefix="jarvis-board-suite-")
23
+ tempfile.tempdir = _TMP
24
+
16
25
  sys.path.insert(0, str(Path(__file__).resolve().parent))
17
26
  from harness.ids import LEDGER as L # noqa: E402 — fixtures render in the repo's dialect
18
27
  from harness import (align, architecture, autonomy, branches, config, coverage, epic, # noqa: E402
@@ -544,7 +553,7 @@ def test_release_flattens_the_done_tier():
544
553
  generate.settle_epic_tier(root)
545
554
  assert (v / "complete" / "publish-under-your-name").is_dir()
546
555
 
547
- version._flatten_done_tier(model.Version(v))
556
+ version._flatten_done_tier(model.Version(v), root)
548
557
  assert (v / "publish-under-your-name").is_dir()
549
558
  assert not (v / "complete").exists()
550
559
 
@@ -5382,6 +5391,159 @@ def test_rule_titles_and_enforcement_read_the_same_headings():
5382
5391
  assert set(registry.rule_titles(body)) == set(registry._parse_rules(body))
5383
5392
 
5384
5393
 
5394
+ def test_every_folder_move_in_the_harness_repairs_its_links():
5395
+ # Three of nine movers repaired their links and six did not, all in the same
5396
+ # way: the snapshot/move/repair dance was pasted in wherever somebody had been
5397
+ # bitten. This is the check that stops a tenth being added the same way — it
5398
+ # reads the source, so a new `shutil.move` outside a `repairing()` block fails
5399
+ # here rather than in somebody's board six weeks later.
5400
+ import ast
5401
+
5402
+ unguarded = []
5403
+ for py in sorted((Path(__file__).parent / "harness").glob("*.py")):
5404
+ tree_ = ast.parse(py.read_text())
5405
+ guarded = set()
5406
+ for node in ast.walk(tree_):
5407
+ if not isinstance(node, ast.With):
5408
+ continue
5409
+ opens = any(isinstance(i.context_expr, ast.Call)
5410
+ and isinstance(i.context_expr.func, ast.Attribute)
5411
+ and i.context_expr.func.attr == "repairing"
5412
+ for i in node.items)
5413
+ if opens:
5414
+ guarded |= {id(d) for b in node.body for d in ast.walk(b)}
5415
+ for node in ast.walk(tree_):
5416
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
5417
+ and node.func.attr == "move"
5418
+ and isinstance(node.func.value, ast.Name)
5419
+ and node.func.value.id == "shutil"
5420
+ and id(node) not in guarded):
5421
+ unguarded.append(f"{py.name}:{node.lineno}")
5422
+
5423
+ assert not unguarded, (
5424
+ "a folder move outside `links.repairing(...)` breaks every link that "
5425
+ f"pointed at it, silently: {', '.join(unguarded)}")
5426
+
5427
+
5428
+ def test_an_epic_leaving_a_cut_repairs_links_to_the_tasks_that_came_with_it():
5429
+ # A two-stage move: the epic relocates AND each task is lifted out of its
5430
+ # bucket underneath it, so a task ends somewhere neither move alone predicts.
5431
+ # Keyed on the half-way path this silently rewrites every inbound link to a
5432
+ # folder that does not exist.
5433
+ with tempfile.TemporaryDirectory() as tmp:
5434
+ v = _tree(tmp)
5435
+ root = Path(tmp)
5436
+ e = _epic(v, "an-epic")
5437
+ (e / "queue").mkdir()
5438
+ _task(e / "queue", "came-along")
5439
+
5440
+ elsewhere = root / "product" / "note.md"
5441
+ elsewhere.parent.mkdir(parents=True, exist_ok=True)
5442
+ elsewhere.write_text(
5443
+ "planned in [the task](../versions/26-cut/an-epic/queue/"
5444
+ "came-along/task.md)\n")
5445
+
5446
+ os.environ["WORK_DIR"] = tmp
5447
+ try:
5448
+ epic.cmd_epic_move({"name": "an-epic", "backlog": "true"})
5449
+ finally:
5450
+ os.environ.pop("WORK_DIR", None)
5451
+
5452
+ landed = root / "versions" / "backlog" / "an-epic" / "came-along" / "task.md"
5453
+ assert landed.is_file(), "fixture: the task did not land where expected"
5454
+ href = re.search(r"\]\(([^)]+)\)", elsewhere.read_text()).group(1)
5455
+ assert (elsewhere.parent / href).resolve() == landed.resolve(), \
5456
+ f"link points at {href}, which is not where the task went"
5457
+
5458
+
5459
+ def test_an_epic_move_snapshots_the_repo_once_not_once_per_task():
5460
+ # The whole-repo scan is ~1s here and slower on a bigger board, so a mover that
5461
+ # moves a task at a time must not pay for it per task. Two of them move in a
5462
+ # loop, which is what made the shared block worth having over a pasted pair.
5463
+ from harness import links as links_mod
5464
+
5465
+ with tempfile.TemporaryDirectory() as tmp:
5466
+ v = _tree(tmp)
5467
+ root = Path(tmp)
5468
+ e = _epic(v, "an-epic")
5469
+ (e / "queue").mkdir()
5470
+ for n in ("one", "two", "three"):
5471
+ _task(e / "queue", n)
5472
+
5473
+ real, calls = links_mod.snapshot, []
5474
+ links_mod.snapshot = lambda repo: (calls.append(repo), real(repo))[1]
5475
+ os.environ["WORK_DIR"] = tmp
5476
+ try:
5477
+ epic.cmd_epic_move({"name": "an-epic", "backlog": "true"})
5478
+ finally:
5479
+ links_mod.snapshot = real
5480
+ os.environ.pop("WORK_DIR", None)
5481
+
5482
+ assert len(calls) == 1, f"scanned the repo {len(calls)} times for 3 tasks"
5483
+
5484
+
5485
+ def _migrate(tmp: str, **flags):
5486
+ """`migrate` against a fixture, with WORK_DIR pointing at it."""
5487
+ from harness import migrate as migrate_mod
5488
+ os.environ["WORK_DIR"] = tmp
5489
+ try:
5490
+ return migrate_mod.cmd_migrate(flags)
5491
+ finally:
5492
+ os.environ.pop("WORK_DIR", None)
5493
+
5494
+
5495
+ def test_migrate_moves_the_backlog_inside_versions_and_repairs_what_pointed_at_it():
5496
+ # The migration used to be a script in a session's scratchpad, which is to say
5497
+ # it did not exist by the time the next repo needed it. The links are why it
5498
+ # cannot be a shell one-liner: the pointers at a moved task live all over a
5499
+ # repo, and a migration scoped to `work/` passes clean and rots the rest.
5500
+ with tempfile.TemporaryDirectory() as tmp:
5501
+ _tree(tmp)
5502
+ root = Path(tmp)
5503
+ (root / "versions" / "backlog").rmdir()
5504
+ old = root / "backlog" / "later-on" / "a-task"
5505
+ old.mkdir(parents=True)
5506
+ (old / "task.md").write_text("---\npriority: P1\n---\n\n# A task\n")
5507
+
5508
+ # A pointer from OUTSIDE work/ — the half a work-scoped migration misses.
5509
+ outside = root.parent / "notes.md"
5510
+ outside.write_text(f"see [it]({root.name}/backlog/later-on/a-task/task.md)\n")
5511
+
5512
+ assert _migrate(tmp) == 0
5513
+ landed = root / "versions" / "backlog" / "later-on" / "a-task" / "task.md"
5514
+ assert landed.is_file(), "the backlog did not move inside versions/"
5515
+ assert not (root / "backlog").exists()
5516
+ assert (root / "versions" / "archive").is_dir()
5517
+
5518
+ href = re.search(r"\]\(([^)]+)\)", outside.read_text()).group(1)
5519
+ assert (outside.parent / href).resolve() == landed.resolve(), \
5520
+ f"a link outside work/ was left pointing at {href}"
5521
+
5522
+
5523
+ def test_migrate_says_nothing_to_do_on_a_repo_already_migrated():
5524
+ # Nobody remembers which repos have been through it, so the safe answer to
5525
+ # running it twice has to be "nothing", not a second move or an error.
5526
+ with tempfile.TemporaryDirectory() as tmp:
5527
+ _tree(tmp)
5528
+ (Path(tmp) / "versions" / "archive").mkdir(exist_ok=True)
5529
+ assert _migrate(tmp) == 0
5530
+ assert not (Path(tmp) / "backlog").exists()
5531
+
5532
+
5533
+ def test_migrate_refuses_a_repo_holding_two_backlogs():
5534
+ # Which copy of a task is the real one is a judgement, and a migration that
5535
+ # guesses at it silently loses whichever it did not pick.
5536
+ with tempfile.TemporaryDirectory() as tmp:
5537
+ _tree(tmp)
5538
+ (Path(tmp) / "backlog").mkdir()
5539
+ try:
5540
+ _migrate(tmp)
5541
+ assert False, "migrating a repo with two backlogs should refuse"
5542
+ except SystemExit:
5543
+ pass
5544
+ assert (Path(tmp) / "backlog").is_dir(), "it moved something before refusing"
5545
+
5546
+
5385
5547
  if __name__ == "__main__":
5386
5548
  tests = [v for k, v in sorted(globals().items())
5387
5549
  if k.startswith("test_") and callable(v)]
@@ -5411,4 +5573,5 @@ if __name__ == "__main__":
5411
5573
  config.apply(config.DEFAULTS)
5412
5574
  fn()
5413
5575
  print(f"ok {fn.__name__}")
5576
+ shutil.rmtree(_TMP, ignore_errors=True)
5414
5577
  print(f"\n{len(tests)} passed")
package/harness/work.py CHANGED
@@ -12,15 +12,17 @@ ledger: a durable rule lives in the domain or system that owns it, and
12
12
  ├── architecture/<system>.md how it is built (hosted rules)
13
13
  ├── design/ quality/ security/ operations/ the remaining domains,
14
14
  │ support/ commercial/ legal/ each hosting its own rules
15
- ├── versions/<v>/version.md a RELEASE permanent record
16
- │ └── <epic>/epic.md the plan-it-together doc; removed at release
17
- └── {queue,in-progress,complete}/<task>/
18
- └── backlog/<epic>/<task>/ epics planned but not yet in a cut
19
-
20
- A VERSION is a release: it states a user-visible `outcome:` and cannot open
21
- while an earlier one is unreleased. An EPIC is temporary — the coherent goal
22
- planned together, archived at release. A TASK is ONE goal, end-to-end,
23
- internally phased, and always belongs to an epic.
15
+ └── versions/ every state a cut's work is in
16
+ ├── NN-<name>/version.md a RELEASE permanent record
17
+ └── <epic>/epic.md the plan-it-together doc; removed at release
18
+ └── {queue,in-progress,blocked,complete}/<task>/
19
+ ├── backlog/<epic>/<task>/ epics planned but not yet in a cut
20
+ └── archive/<cut>/ a cut taken off the board unreleased
21
+
22
+ A VERSION is a release: it states a user-visible `outcome:`, and its folder is
23
+ named `NN-<name>` because the order is part of the name and nothing derives it.
24
+ An EPIC is temporary — the coherent goal planned together, archived at release.
25
+ A TASK is ONE goal, end-to-end, internally phased, and always belongs to an epic.
24
26
 
25
27
  A task's status is the bucket it sits in — never a frontmatter field; a backlog
26
28
  task has none until pulled, which is why the backlog has no buckets. A version's
@@ -53,6 +55,7 @@ Subcommands:
53
55
  the opening prompt for the NEXT session, derived —
54
56
  and how to start it where `session.mcp` names a server
55
57
  release <v> archive <v> [--dry-run]
58
+ migrate [--dry-run] [--force] bring work/ up to the layout this harness expects
56
59
  init [--project <dir>] scaffold work/ + the nine domains (idempotent)
57
60
  doctor [--project <dir>] runtime, config, tree, ids, graph — self-diagnosis
58
61
  id-new --host <name> --title "…" claim the next free rule id, atomically
@@ -150,6 +153,7 @@ from harness.gate import cmd_observed, cmd_verify
150
153
  from harness.kickoff import cmd_kickoff
151
154
  from harness.git import cmd_sync
152
155
  from harness.branches import cmd_find
156
+ from harness.migrate import cmd_migrate
153
157
 
154
158
 
155
159
  #: Every subcommand the ladder in `main` dispatches, in the order it tries them.
@@ -161,7 +165,7 @@ SUBCOMMANDS = (
161
165
  "epic-new", "feature-new", "version-new", "place", "handoff", "release", "archive",
162
166
  "find", "list", "readme", "move", "plan", "session", "kickoff", "path", "code",
163
167
  "domain-new", "system-new", "where", "rules", "align", "wrap", "coverage",
164
- "migrate-owner", "next", "status", "drop", "ask", "answer", "needs", "verify",
168
+ "migrate", "migrate-owner", "next", "status", "drop", "ask", "answer", "needs", "verify",
165
169
  "observed", "log", "digest", "sync",
166
170
  )
167
171
 
@@ -489,6 +493,8 @@ def dispatch(cmd, pos, flags, cfg) -> int:
489
493
  return cmd_wrap(cfg, flags, _project_root(flags))
490
494
  if cmd == "coverage":
491
495
  return cmd_coverage(flags)
496
+ if cmd == "migrate":
497
+ return cmd_migrate(flags)
492
498
  if cmd == "migrate-owner":
493
499
  return cmd_migrate_owner(flags)
494
500
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
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",
61
60
  "@jarvis/board": "0.1.0",
62
61
  "@jarvis/data": "0.1.0",
63
62
  "@jarvis/errors": "1.0.0",
64
- "@jarvis/logger": "1.0.0",
65
63
  "@jarvis/rpc": "1.0.0",
66
64
  "@jarvis/types": "1.0.0",
67
65
  "@jarvis/typescript-config": "1.0.0",
68
66
  "@jarvis/ui": "0.1.0",
69
- "@jarvis/vitest-config": "1.0.0"
67
+ "@jarvis/vitest-config": "1.0.0",
68
+ "@jarvis/anthropic": "1.0.0",
69
+ "@jarvis/logger": "1.0.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",