@appchy/jarvis 0.1.72 → 0.1.74
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/harness/harness/autonomy.py +7 -3
- package/harness/harness/epic.py +31 -14
- package/harness/harness/generate.py +19 -8
- package/harness/harness/links.py +52 -2
- package/harness/harness/migrate.py +179 -0
- package/harness/harness/shift.py +4 -2
- package/harness/harness/task.py +9 -19
- package/harness/harness/version.py +15 -11
- package/harness/test_work.py +190 -1
- package/harness/work.py +16 -10
- package/package.json +5 -5
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 = "
|
|
10112
|
+
var SHA = "9566241";
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
package/harness/harness/epic.py
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
package/harness/harness/links.py
CHANGED
|
@@ -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
|
-
|
|
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,179 @@
|
|
|
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 _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
|
+
|
|
69
|
+
def cmd_migrate(args) -> int:
|
|
70
|
+
"""`work/backlog/` and the archive both move inside `versions/`.
|
|
71
|
+
|
|
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.
|
|
74
|
+
"""
|
|
75
|
+
root = find_work_root()
|
|
76
|
+
repo = root.parent
|
|
77
|
+
dry = bool(args.get("dry_run") or args.get("dry-run"))
|
|
78
|
+
|
|
79
|
+
old = root / "backlog"
|
|
80
|
+
new = backlog_dir(root)
|
|
81
|
+
archive = archive_dir(root)
|
|
82
|
+
cuts = _archived_cuts(root)
|
|
83
|
+
|
|
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.
|
|
87
|
+
if old.is_dir() and new.is_dir():
|
|
88
|
+
die(f"both {rel(old, root)} and {rel(new, root)} exist — this repo has two "
|
|
89
|
+
f"backlogs and only a person can say which task is the real one. "
|
|
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.")
|
|
96
|
+
|
|
97
|
+
todo = []
|
|
98
|
+
if old.is_dir():
|
|
99
|
+
todo.append("the backlog moves inside versions/")
|
|
100
|
+
if cuts:
|
|
101
|
+
todo.append(f"{len(cuts)} archived cut(s) move inside versions/")
|
|
102
|
+
if not archive.is_dir():
|
|
103
|
+
todo.append("versions/archive/ is created")
|
|
104
|
+
if not todo:
|
|
105
|
+
print(f"{rel(root, root)} is already on the current layout — nothing to do")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
if dry:
|
|
109
|
+
print(f"would migrate {rel(root, root)}:")
|
|
110
|
+
for line in todo:
|
|
111
|
+
print(f" {line}")
|
|
112
|
+
if old.is_dir():
|
|
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}")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
dirty = _uncommitted(repo, root)
|
|
119
|
+
if dirty and not args.get("force"):
|
|
120
|
+
die(f"{len(dirty)} uncommitted change(s) under {rel(root, root)} — a layout "
|
|
121
|
+
f"migration moves folders somebody may have open, and git is the only "
|
|
122
|
+
f"way back. Commit or stash first, then run this again (--force "
|
|
123
|
+
f"overrides).\n " + "\n ".join(dirty[:10]))
|
|
124
|
+
|
|
125
|
+
if old.is_dir():
|
|
126
|
+
moved_tasks = sum(1 for _ in old.rglob("task.md"))
|
|
127
|
+
new.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
with links.repairing(repo) as moved:
|
|
129
|
+
moved[old.resolve()] = new.resolve()
|
|
130
|
+
shutil.move(str(old), str(new))
|
|
131
|
+
print(f"moved {rel(old, root)} -> {rel(new, root)} ({moved_tasks} task(s))")
|
|
132
|
+
|
|
133
|
+
# Reported, never rewritten. Whether a sentence naming the old path is
|
|
134
|
+
# stale or a deliberate record of what was true then is a question only a
|
|
135
|
+
# person can answer — and this is the half no link check ever sees.
|
|
136
|
+
stale = links.stale_mentions(repo, "work/backlog", new)
|
|
137
|
+
if stale:
|
|
138
|
+
print(f" {len(stale)} line(s) still name `work/backlog` in prose — "
|
|
139
|
+
f"read them, they are not all wrong:")
|
|
140
|
+
for hit in stale[:10]:
|
|
141
|
+
print(f" {hit}")
|
|
142
|
+
if len(stale) > 10:
|
|
143
|
+
print(f" … and {len(stale) - 10} more")
|
|
144
|
+
|
|
145
|
+
if not archive.is_dir():
|
|
146
|
+
archive.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
# An empty directory does not survive git, and the point of making it now
|
|
148
|
+
# is that `archive` has somewhere to put a cut without inventing it later.
|
|
149
|
+
(archive / ".gitkeep").write_text("")
|
|
150
|
+
print(f"created {rel(archive, root)}")
|
|
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
|
+
|
|
177
|
+
print("done — commit this, then every machine working this repo needs a CLI "
|
|
178
|
+
"new enough to read it")
|
|
179
|
+
return 0
|
package/harness/harness/shift.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/harness/harness/task.py
CHANGED
|
@@ -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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -339,14 +334,9 @@ def cmd_move(args) -> int:
|
|
|
339
334
|
# moves the folder and breaks whatever pointed at it — and `move` fires on every
|
|
340
335
|
# pickup and every completion, far more often than `place`. One audited board
|
|
341
336
|
# had 16 of its 37 broken links from a bucket change alone.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
repaired, unresolved = links.repair(before, {moved_from: dest.resolve()})
|
|
346
|
-
if repaired:
|
|
347
|
-
print(f" repaired {repaired} link(s) that pointed at it")
|
|
348
|
-
for line in unresolved:
|
|
349
|
-
print(f" COULD NOT PLACE {line}")
|
|
337
|
+
with links.repairing(root.parent) as moved:
|
|
338
|
+
moved[task.folder.resolve()] = dest.resolve()
|
|
339
|
+
shutil.move(str(task.folder), str(dest))
|
|
350
340
|
|
|
351
341
|
def mutate(d):
|
|
352
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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)
|
package/harness/test_work.py
CHANGED
|
@@ -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,185 @@ 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_brings_already_archived_cuts_to_where_lookups_look():
|
|
5524
|
+
# The layout change moved where `archive` WRITES without moving what it had
|
|
5525
|
+
# already written, and `locate_version` only reads the new place — so every
|
|
5526
|
+
# cut a repo had archived answered "no version named that". jarvis had
|
|
5527
|
+
# archived none, which is why this survived the first migration.
|
|
5528
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
5529
|
+
_tree(tmp)
|
|
5530
|
+
root = Path(tmp)
|
|
5531
|
+
(root / "versions" / "backlog").rmdir()
|
|
5532
|
+
(root / "backlog").mkdir()
|
|
5533
|
+
old = root / "archive" / "versions" / "09-shipped"
|
|
5534
|
+
old.mkdir(parents=True)
|
|
5535
|
+
(old / "version.md").write_text(
|
|
5536
|
+
"---\ncreated: 2026-01-01\norder: 9\noutcome: x\nreleased: 2026-02-01\n"
|
|
5537
|
+
"---\n\n# Shipped\n")
|
|
5538
|
+
# Something the harness does not own, in the same directory.
|
|
5539
|
+
(root / "archive" / "backlog" / "an-old-idea").mkdir(parents=True)
|
|
5540
|
+
|
|
5541
|
+
assert _migrate(tmp) == 0
|
|
5542
|
+
assert (root / "versions" / "archive" / "09-shipped" / "version.md").is_file()
|
|
5543
|
+
assert model.locate_version(root, "09-shipped") is not None, \
|
|
5544
|
+
"an archived cut is still not lookupable after migrating"
|
|
5545
|
+
assert (root / "archive" / "backlog" / "an-old-idea").is_dir(), \
|
|
5546
|
+
"the migration moved records the harness does not own"
|
|
5547
|
+
|
|
5548
|
+
|
|
5549
|
+
def test_migrate_says_nothing_to_do_on_a_repo_already_migrated():
|
|
5550
|
+
# Nobody remembers which repos have been through it, so the safe answer to
|
|
5551
|
+
# running it twice has to be "nothing", not a second move or an error.
|
|
5552
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
5553
|
+
_tree(tmp)
|
|
5554
|
+
(Path(tmp) / "versions" / "archive").mkdir(exist_ok=True)
|
|
5555
|
+
assert _migrate(tmp) == 0
|
|
5556
|
+
assert not (Path(tmp) / "backlog").exists()
|
|
5557
|
+
|
|
5558
|
+
|
|
5559
|
+
def test_migrate_refuses_a_repo_holding_two_backlogs():
|
|
5560
|
+
# Which copy of a task is the real one is a judgement, and a migration that
|
|
5561
|
+
# guesses at it silently loses whichever it did not pick.
|
|
5562
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
5563
|
+
_tree(tmp)
|
|
5564
|
+
(Path(tmp) / "backlog").mkdir()
|
|
5565
|
+
try:
|
|
5566
|
+
_migrate(tmp)
|
|
5567
|
+
assert False, "migrating a repo with two backlogs should refuse"
|
|
5568
|
+
except SystemExit:
|
|
5569
|
+
pass
|
|
5570
|
+
assert (Path(tmp) / "backlog").is_dir(), "it moved something before refusing"
|
|
5571
|
+
|
|
5572
|
+
|
|
5385
5573
|
if __name__ == "__main__":
|
|
5386
5574
|
tests = [v for k, v in sorted(globals().items())
|
|
5387
5575
|
if k.startswith("test_") and callable(v)]
|
|
@@ -5411,4 +5599,5 @@ if __name__ == "__main__":
|
|
|
5411
5599
|
config.apply(config.DEFAULTS)
|
|
5412
5600
|
fn()
|
|
5413
5601
|
print(f"ok {fn.__name__}")
|
|
5602
|
+
shutil.rmtree(_TMP, ignore_errors=True)
|
|
5414
5603
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.74",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -56,17 +56,17 @@
|
|
|
56
56
|
"tsup": "^8.5.1",
|
|
57
57
|
"typescript": "^5.7.0",
|
|
58
58
|
"vitest": "^2.1.0",
|
|
59
|
-
"@jarvis/
|
|
59
|
+
"@jarvis/anthropic": "1.0.0",
|
|
60
60
|
"@jarvis/board": "0.1.0",
|
|
61
61
|
"@jarvis/data": "0.1.0",
|
|
62
|
+
"@jarvis/errors": "1.0.0",
|
|
62
63
|
"@jarvis/logger": "1.0.0",
|
|
63
64
|
"@jarvis/rpc": "1.0.0",
|
|
65
|
+
"@jarvis/types": "1.0.0",
|
|
64
66
|
"@jarvis/typescript-config": "1.0.0",
|
|
65
67
|
"@jarvis/ui": "0.1.0",
|
|
66
68
|
"@jarvis/vitest-config": "1.0.0",
|
|
67
|
-
"@jarvis/
|
|
68
|
-
"@jarvis/types": "1.0.0",
|
|
69
|
-
"@jarvis/errors": "1.0.0"
|
|
69
|
+
"@jarvis/agents": "1.0.0"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|
|
72
72
|
"dev": "tsx watch src/bin.ts start --foreground",
|