@appchy/jarvis 0.1.79 → 0.1.80
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 +14 -5
- package/dist/bin.js.map +1 -1
- package/dist/ui/app.js +25 -25
- package/harness/harness/architecture.py +6 -1
- package/harness/harness/generate.py +18 -1
- package/harness/harness/migrate.py +15 -7
- package/harness/harness/model.py +33 -5
- package/harness/harness/report.py +18 -2
- package/harness/harness/tree.py +22 -1
- package/harness/harness/version.py +27 -5
- package/harness/test_work.py +101 -3
- package/harness/work.py +1 -0
- package/package.json +3 -3
|
@@ -131,7 +131,12 @@ def cmd_init(args) -> int:
|
|
|
131
131
|
|
|
132
132
|
write(root / "README.md", _README)
|
|
133
133
|
write(root / "ROADMAP.md", _ROADMAP)
|
|
134
|
-
|
|
134
|
+
# All THREE states a cut's work is in, not two. `complete/` was missing here
|
|
135
|
+
# and in `migrate`, so the folder a released cut belongs in did not exist until
|
|
136
|
+
# something created it — and the first repo to release a cut had nowhere to put
|
|
137
|
+
# it that was not a lie about what happened.
|
|
138
|
+
for d in ("versions", "versions/backlog", "versions/archive",
|
|
139
|
+
"versions/complete"):
|
|
135
140
|
(root / d).mkdir(parents=True, exist_ok=True)
|
|
136
141
|
# Reading order, not alphabetical — and read from config, so a repo that added a
|
|
137
142
|
# tenth domain gets it scaffolded too rather than having to remember to.
|
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from .tree import (BACKLOG_END, BACKLOG_START, DONE_TIER, SYSTEMS_END,
|
|
9
9
|
SYSTEMS_START, die, rel)
|
|
10
|
-
from .model import Task, Version, _ordered, scan
|
|
10
|
+
from .model import Task, Version, _ordered, scan, scan_shipped
|
|
11
11
|
from .registry import rules_by_domain, scan_domains, scan_systems
|
|
12
12
|
from .lint import print_lint
|
|
13
13
|
|
|
@@ -105,6 +105,23 @@ def _regen_readme(root: Path) -> str:
|
|
|
105
105
|
rows.append(f"| [{d.name}]({d.name}/README.md) | {counts.get(d.name) or '—'} |")
|
|
106
106
|
sections.insert(0, "\n".join(rows))
|
|
107
107
|
|
|
108
|
+
# What this repo has DELIVERED, last and one line each — the mirror of the
|
|
109
|
+
# SHIPPED block in `list`, and here for the same reason. A README whose board
|
|
110
|
+
# simply stops at the live cuts describes a repo that has never released
|
|
111
|
+
# anything, which was false the day the first cut shipped.
|
|
112
|
+
shipped = scan_shipped(root)
|
|
113
|
+
if shipped:
|
|
114
|
+
rows = ["### Shipped _( released and off the board — the record is in "
|
|
115
|
+
"`versions/complete/` )_", ""]
|
|
116
|
+
for v in shipped:
|
|
117
|
+
link = f"[{v.title}]({_relpath_from_readme(v.md, root)})"
|
|
118
|
+
n = len(v.all_tasks())
|
|
119
|
+
rows.append(f"- **{link}** — `{v.name}` · released {v.released} "
|
|
120
|
+
f"· {n} task(s)")
|
|
121
|
+
if v.outcome:
|
|
122
|
+
rows.append(f" <br>{v.outcome}")
|
|
123
|
+
sections.append("\n".join(rows))
|
|
124
|
+
|
|
108
125
|
block = "\n\n".join(sections) if sections else "_backlog is empty_"
|
|
109
126
|
# The replacement is a CALLABLE so that what goes in comes back out. A title is
|
|
110
127
|
# something a person typed, and as a replacement template `\d` is a bad escape
|
|
@@ -24,7 +24,7 @@ import shutil
|
|
|
24
24
|
import subprocess
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
|
-
from .tree import archive_dir, backlog_dir, die, find_work_root, rel
|
|
27
|
+
from .tree import archive_dir, backlog_dir, complete_dir, die, find_work_root, rel
|
|
28
28
|
from . import links
|
|
29
29
|
|
|
30
30
|
|
|
@@ -79,6 +79,7 @@ def cmd_migrate(args) -> int:
|
|
|
79
79
|
old = root / "backlog"
|
|
80
80
|
new = backlog_dir(root)
|
|
81
81
|
archive = archive_dir(root)
|
|
82
|
+
complete = complete_dir(root)
|
|
82
83
|
cuts = _archived_cuts(root)
|
|
83
84
|
|
|
84
85
|
# The hard refusals. Two of anything means somebody has already started this,
|
|
@@ -101,6 +102,8 @@ def cmd_migrate(args) -> int:
|
|
|
101
102
|
todo.append(f"{len(cuts)} archived cut(s) move inside versions/")
|
|
102
103
|
if not archive.is_dir():
|
|
103
104
|
todo.append("versions/archive/ is created")
|
|
105
|
+
if not complete.is_dir():
|
|
106
|
+
todo.append("versions/complete/ is created")
|
|
104
107
|
if not todo:
|
|
105
108
|
print(f"{rel(root, root)} is already on the current layout — nothing to do")
|
|
106
109
|
return 0
|
|
@@ -142,12 +145,17 @@ def cmd_migrate(args) -> int:
|
|
|
142
145
|
if len(stale) > 10:
|
|
143
146
|
print(f" … and {len(stale) - 10} more")
|
|
144
147
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
# Both off-board homes, for the same reason: an empty directory does not
|
|
149
|
+
# survive git, and the point of making them now is that `archive` has somewhere
|
|
150
|
+
# to put a cut without inventing it later — under either outcome. A repo that
|
|
151
|
+
# migrated before `complete/` existed gets it here rather than on the day it
|
|
152
|
+
# first releases something, which is the worst moment to find a folder missing.
|
|
153
|
+
for d in (archive, complete):
|
|
154
|
+
if d.is_dir():
|
|
155
|
+
continue
|
|
156
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
(d / ".gitkeep").write_text("")
|
|
158
|
+
print(f"created {rel(d, root)}")
|
|
151
159
|
|
|
152
160
|
if cuts:
|
|
153
161
|
# One block for every cut. Each carries a whole released version's worth of
|
package/harness/harness/model.py
CHANGED
|
@@ -310,15 +310,43 @@ def locate(root: Path, name: str):
|
|
|
310
310
|
return t
|
|
311
311
|
return None
|
|
312
312
|
def locate_version(root: Path, name: str):
|
|
313
|
-
"""Find a version by name. Looks in
|
|
314
|
-
|
|
315
|
-
answering, while `scan` (and
|
|
316
|
-
sees only live work.
|
|
317
|
-
|
|
313
|
+
"""Find a version by name. Looks in BOTH homes a cut leaves the board for —
|
|
314
|
+
`versions/complete/` and `versions/archive/` — so a finished cut stays
|
|
315
|
+
LOOKUPABLE after it goes, and `path` keeps answering, while `scan` (and
|
|
316
|
+
therefore `list`, the README and every lint) sees only live work. Off the
|
|
317
|
+
board is not gone.
|
|
318
|
+
|
|
319
|
+
Both are named here rather than one, because a lookup that knows only the
|
|
320
|
+
home a cut USED to have is how a move makes a cut vanish: 232 backlog rows
|
|
321
|
+
dropped out of one README when the tree moved under a reader that had not."""
|
|
322
|
+
for base in ("versions", "versions/complete", "versions/archive"):
|
|
318
323
|
folder = root / base / name
|
|
319
324
|
if (folder / "version.md").is_file():
|
|
320
325
|
return Version(folder)
|
|
321
326
|
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def scan_shipped(root: Path) -> list:
|
|
330
|
+
"""Every cut filed under `versions/complete/`, newest release first.
|
|
331
|
+
|
|
332
|
+
Deliberately NOT part of `scan`. `scan` answers "what is moving", and a shipped
|
|
333
|
+
cut is not — that separation is why archiving one stopped `list` having to be
|
|
334
|
+
read past 60 finished tasks. This answers the other question, "what has this
|
|
335
|
+
repo actually delivered", and its two readers (`list` and the README) render one
|
|
336
|
+
LINE per cut from it rather than its tasks.
|
|
337
|
+
|
|
338
|
+
Sorted by release date descending so the most recent thing shipped reads first.
|
|
339
|
+
Two passes rather than one reversed key: a same-day pair of cuts must still read
|
|
340
|
+
in roadmap order, and reversing a compound key would stand those on their head
|
|
341
|
+
too."""
|
|
342
|
+
cdir = root / "versions" / "complete"
|
|
343
|
+
if not cdir.is_dir():
|
|
344
|
+
return []
|
|
345
|
+
cuts = [Version(v) for v in sorted(cdir.iterdir())
|
|
346
|
+
if v.is_dir() and (v / "version.md").is_file()]
|
|
347
|
+
cuts.sort(key=lambda v: (v.order, v.name))
|
|
348
|
+
cuts.sort(key=lambda v: v.released or "", reverse=True)
|
|
349
|
+
return cuts
|
|
322
350
|
def locate_epic(root: Path, name: str):
|
|
323
351
|
"""Find an epic by name anywhere — in a version or in the backlog. Names are
|
|
324
352
|
globally unique across tasks, epics and versions, so a name alone resolves."""
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
from .tree import find_work_root, rel
|
|
7
7
|
from .frontmatter import _eol, read_item, split_frontmatter, write_item
|
|
8
|
-
from .model import Task, _ordered, scan
|
|
8
|
+
from .model import Task, _ordered, scan, scan_shipped
|
|
9
9
|
from .lint import lint_warnings
|
|
10
10
|
from .generate import _regen_readme, _sync, settle_epic_tier
|
|
11
11
|
from .align import (_align_acceptance, _align_agents, _align_citations,
|
|
@@ -69,7 +69,12 @@ def cmd_list(args) -> int:
|
|
|
69
69
|
for d in domains]
|
|
70
70
|
print("DOMAINS " + " · ".join(cells) + " (n) = rules hosted")
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
# READ BEFORE THE EMPTINESS TEST, because a shipped cut is not nothing. A repo
|
|
73
|
+
# whose only cut has been released has no live versions and no backlog, and the
|
|
74
|
+
# early return printed "backlog is empty" over the top of a delivered release —
|
|
75
|
+
# the precise reading this block exists to stop.
|
|
76
|
+
shipped = scan_shipped(root)
|
|
77
|
+
if not s["versions"] and not s["backlog"] and not shipped:
|
|
73
78
|
print("\nbacklog is empty")
|
|
74
79
|
return 0
|
|
75
80
|
|
|
@@ -116,6 +121,17 @@ def cmd_list(args) -> int:
|
|
|
116
121
|
print()
|
|
117
122
|
for t in loose:
|
|
118
123
|
print(f" {_task_line(t)}")
|
|
124
|
+
|
|
125
|
+
# WHAT THIS REPO HAS ACTUALLY DELIVERED, at the bottom and one line each.
|
|
126
|
+
# A shipped cut leaves the live board — that is what made this list readable —
|
|
127
|
+
# but leaving it out entirely made a repo with a release behind it read exactly
|
|
128
|
+
# like a repo that has never shipped anything. The count is the whole cut, so
|
|
129
|
+
# `142/142` says a release closed rather than a bucket did.
|
|
130
|
+
for v in shipped:
|
|
131
|
+
n = len(v.all_tasks())
|
|
132
|
+
print(f"\nSHIPPED {v.name} — {v.title} · released {v.released} · {n}/{n}")
|
|
133
|
+
if v.outcome:
|
|
134
|
+
print(f" {v.outcome}")
|
|
119
135
|
print()
|
|
120
136
|
warns = lint_warnings(root)
|
|
121
137
|
if warns:
|
package/harness/harness/tree.py
CHANGED
|
@@ -218,8 +218,29 @@ def backlog_dir(root: Path) -> Path:
|
|
|
218
218
|
def archive_dir(root: Path) -> Path:
|
|
219
219
|
"""Where a finished cut goes when releasing it would be a lie. A cut whose work
|
|
220
220
|
all moved elsewhere has nothing left to deliver and no outcome it met; without
|
|
221
|
-
this it sits in the live list looking open forever.
|
|
221
|
+
this it sits in the live list looking open forever.
|
|
222
|
+
|
|
223
|
+
One of TWO homes a cut leaves the board for, and the other is `complete_dir`.
|
|
224
|
+
Which one is not a choice a caller makes: `archive` reads the `released:` stamp,
|
|
225
|
+
because whether a cut shipped is a fact the tree already holds."""
|
|
222
226
|
return root / "versions" / "archive"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def complete_dir(root: Path) -> Path:
|
|
230
|
+
"""Where a cut that SHIPPED is filed — released, outcome met, nothing owed.
|
|
231
|
+
|
|
232
|
+
The third folder of `versions/{backlog,archive,complete}`, and it exists because
|
|
233
|
+
the other two could not say this. A released cut sent to `archive/` is filed
|
|
234
|
+
under "releasing would be a lie" — the exact opposite of what happened to it —
|
|
235
|
+
and one left in `versions/` sits among the live cuts looking open. Measured: a
|
|
236
|
+
repo released its first cut and had nowhere to put it, so it stayed on the board
|
|
237
|
+
for a day rather than be described wrongly.
|
|
238
|
+
|
|
239
|
+
Off the live board is not gone. `locate_version` resolves a cut here, and the
|
|
240
|
+
board keeps ONE line for it — what it was, when it shipped, what it delivered —
|
|
241
|
+
because a board that drops its releases reads as a board that has never shipped
|
|
242
|
+
anything."""
|
|
243
|
+
return root / "versions" / "complete"
|
|
223
244
|
def assets_dir() -> Path:
|
|
224
245
|
return PAYLOAD_DIR / "assets"
|
|
225
246
|
def rel(p: Path, root: Path) -> str:
|
|
@@ -5,7 +5,8 @@ from datetime import date
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from . import ids
|
|
8
|
-
from .tree import archive_dir, DONE_TIER, VERSION_FM_ORDER, cli, die,
|
|
8
|
+
from .tree import (archive_dir, complete_dir, DONE_TIER, VERSION_FM_ORDER, cli, die,
|
|
9
|
+
find_work_root, rel)
|
|
9
10
|
from .frontmatter import rewrite_file
|
|
10
11
|
from .model import _is_epic_dir, locate_version, scan
|
|
11
12
|
from .scaffold import _check_unused, _check_version_name, _scaffold_version
|
|
@@ -199,7 +200,8 @@ def cmd_release(args) -> int:
|
|
|
199
200
|
print("\nDistill before archiving:")
|
|
200
201
|
print(" 1. Promote still-load-bearing decisions to the domain or system that owns them")
|
|
201
202
|
print(f" 2. Repoint any inbound deep-links to those {ids.LEDGER}-nn entries")
|
|
202
|
-
print(f" 3. {cli()} archive {name} (strips each task to task.md
|
|
203
|
+
print(f" 3. {cli()} archive {name} (strips each task to task.md, and "
|
|
204
|
+
f"files a RELEASED cut under versions/complete/)")
|
|
203
205
|
_sync(root)
|
|
204
206
|
return 0
|
|
205
207
|
def _short_sha() -> str:
|
|
@@ -233,6 +235,21 @@ def _contained(p: Path, root: Path) -> Path:
|
|
|
233
235
|
return rp
|
|
234
236
|
|
|
235
237
|
|
|
238
|
+
def _home_for(version, root: Path) -> Path:
|
|
239
|
+
"""Which of the two off-board homes this cut belongs in, read from the tree.
|
|
240
|
+
|
|
241
|
+
A cut carrying `released:` SHIPPED, and goes to `versions/complete/`. One
|
|
242
|
+
without it is leaving the board for the other reason — its work moved elsewhere
|
|
243
|
+
and there is no outcome it honestly met — and goes to `versions/archive/`,
|
|
244
|
+
which is what that folder's own description has always said it was for.
|
|
245
|
+
|
|
246
|
+
Not a flag, and deliberately not a second verb. Whether a cut shipped is a fact
|
|
247
|
+
the tree already holds, and asking the caller to restate it is how the folder
|
|
248
|
+
and the stamp end up disagreeing — the same two-writers-for-one-fact that makes
|
|
249
|
+
a status field drift from the bucket it sits in."""
|
|
250
|
+
return complete_dir(root) if version.released else archive_dir(root)
|
|
251
|
+
|
|
252
|
+
|
|
236
253
|
def cmd_archive(args) -> int:
|
|
237
254
|
root = find_work_root()
|
|
238
255
|
name = args["name"]
|
|
@@ -285,7 +302,7 @@ def cmd_archive(args) -> int:
|
|
|
285
302
|
removed += 1
|
|
286
303
|
|
|
287
304
|
if dry:
|
|
288
|
-
dest =
|
|
305
|
+
dest = _home_for(version, root) / name
|
|
289
306
|
print(f" stamp archived: on {rel(version.md, root)}")
|
|
290
307
|
print(f" move {rel(version.folder, root)} -> {rel(dest, root)}")
|
|
291
308
|
print(f"\n {removed} file(s)/dir(s) would be stripped. "
|
|
@@ -309,7 +326,7 @@ def cmd_archive(args) -> int:
|
|
|
309
326
|
# past 60 completed tasks to find the 6 that were actually moving. The
|
|
310
327
|
# record is preserved verbatim in `archive/versions/<v>/`, where `path` and
|
|
311
328
|
# `where` still resolve it — it is just no longer in the way.
|
|
312
|
-
dest = _contained(
|
|
329
|
+
dest = _contained(_home_for(version, root) / name, root)
|
|
313
330
|
if dest.exists():
|
|
314
331
|
die(f"{rel(dest, root)} already exists")
|
|
315
332
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -319,7 +336,12 @@ def cmd_archive(args) -> int:
|
|
|
319
336
|
|
|
320
337
|
where = f" — full docs in git history @ {sha}" if sha else ""
|
|
321
338
|
events.append(root, "archived", name, stripped=removed)
|
|
322
|
-
|
|
339
|
+
# SAY WHICH, and say it in the words of what happened rather than the verb's
|
|
340
|
+
# name: "archived" reads as binned, and a cut that shipped has just been filed
|
|
341
|
+
# under the opposite of that. The verb is one because the move is one; the
|
|
342
|
+
# sentence is two because the outcomes are.
|
|
343
|
+
did = "filed as complete — it shipped" if version.released else "archived"
|
|
344
|
+
print(f"{did}: '{name}' stripped {removed} file(s) to task.md, "
|
|
323
345
|
f"moved to {rel(dest, root)}{where}")
|
|
324
346
|
_sync(root)
|
|
325
347
|
return 0
|
package/harness/test_work.py
CHANGED
|
@@ -26,7 +26,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
26
26
|
from harness.ids import LEDGER as L # noqa: E402 — fixtures render in the repo's dialect
|
|
27
27
|
from harness import (align, architecture, autonomy, branches, config, coverage, epic, # noqa: E402
|
|
28
28
|
events, extend, frontmatter, gate, generate, git, ids, kickoff,
|
|
29
|
-
lint, model, peers, registry, safety, scaffold,
|
|
29
|
+
lint, model, peers, registry, report, safety, scaffold,
|
|
30
30
|
shift, task, tree, version)
|
|
31
31
|
|
|
32
32
|
# `parse_argv` is the ENTRY's own concern, so it is loaded from work.py by path
|
|
@@ -967,14 +967,18 @@ def test_release_does_not_treat_a_flat_versions_completed_tasks_as_epics():
|
|
|
967
967
|
("flat-shipped-thing", "complete")]
|
|
968
968
|
|
|
969
969
|
|
|
970
|
-
def
|
|
970
|
+
def test_archive_moves_an_unreleased_cut_off_the_board_but_keeps_it_lookupable():
|
|
971
971
|
# Archived is OFF THE BOARD, not gone: `scan` (and therefore list, the
|
|
972
972
|
# README and every lint) stops seeing it, while `locate_version` still
|
|
973
973
|
# resolves it so `path` and `where` keep answering.
|
|
974
|
+
#
|
|
975
|
+
# NO `released:` HERE, and that is the point of the fixture rather than an
|
|
976
|
+
# omission: `versions/archive/` is where a cut goes when releasing it would be
|
|
977
|
+
# a lie. The released arm lands somewhere else and has its own test below.
|
|
974
978
|
import os
|
|
975
979
|
with tempfile.TemporaryDirectory() as tmp:
|
|
976
980
|
root = Path(tmp)
|
|
977
|
-
v = _tree(tmp, "26-cut"
|
|
981
|
+
v = _tree(tmp, "26-cut")
|
|
978
982
|
e = _epic(v, "an-epic")
|
|
979
983
|
_task(e / "complete", "shipped-thing")
|
|
980
984
|
(e / "complete" / "shipped-thing" / "plan.md").write_text("design\n")
|
|
@@ -995,6 +999,100 @@ def test_archive_moves_the_version_off_the_board_but_keeps_it_lookupable():
|
|
|
995
999
|
assert model.locate_version(root, "26-cut") is not None
|
|
996
1000
|
|
|
997
1001
|
|
|
1002
|
+
def test_archive_files_a_released_cut_under_complete_not_archive():
|
|
1003
|
+
# THE ARM THAT DID NOT EXIST. A cut carrying `released:` shipped — it met its
|
|
1004
|
+
# outcome and owes nothing — so filing it under "releasing would be a lie" is
|
|
1005
|
+
# the one description of it that is false. It goes to `versions/complete/`, and
|
|
1006
|
+
# `archive/` is not even created on this path.
|
|
1007
|
+
#
|
|
1008
|
+
# Same strip either way: what a task carries beyond its brief is the working
|
|
1009
|
+
# papers of a session, and `release` already tells you to lift anything still
|
|
1010
|
+
# load-bearing into the domain that owns it before this runs.
|
|
1011
|
+
import os
|
|
1012
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1013
|
+
root = Path(tmp)
|
|
1014
|
+
v = _tree(tmp, "26-cut", released="2026-08-01")
|
|
1015
|
+
e = _epic(v, "an-epic")
|
|
1016
|
+
_task(e / "complete", "shipped-thing")
|
|
1017
|
+
(e / "complete" / "shipped-thing" / "handoff.md").write_text("notes\n")
|
|
1018
|
+
os.environ["WORK_DIR"] = tmp
|
|
1019
|
+
try:
|
|
1020
|
+
version.cmd_archive({"name": "26-cut"})
|
|
1021
|
+
finally:
|
|
1022
|
+
os.environ.pop("WORK_DIR")
|
|
1023
|
+
|
|
1024
|
+
assert not (root / "versions" / "26-cut").exists()
|
|
1025
|
+
assert not (root / "versions" / "archive" / "26-cut").exists()
|
|
1026
|
+
dest = root / "versions" / "complete" / "26-cut"
|
|
1027
|
+
assert (dest / "version.md").is_file()
|
|
1028
|
+
assert (dest / "an-epic" / "complete" / "shipped-thing" / "task.md").is_file()
|
|
1029
|
+
assert not (dest / "an-epic" / "complete" / "shipped-thing" / "handoff.md").exists()
|
|
1030
|
+
|
|
1031
|
+
# Off the LIVE board, still resolvable by name — `path` keeps answering.
|
|
1032
|
+
assert model.scan(root)["versions"] == []
|
|
1033
|
+
found = model.locate_version(root, "26-cut")
|
|
1034
|
+
assert found is not None
|
|
1035
|
+
assert found.folder == dest
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def test_archive_dry_run_names_the_home_a_released_cut_is_going_to():
|
|
1039
|
+
# A dry run whose printed destination is not the real one is worse than no dry
|
|
1040
|
+
# run: it is a rehearsal of a different command. Both arms read the same stamp.
|
|
1041
|
+
import io, os, contextlib
|
|
1042
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1043
|
+
v = _tree(tmp, "26-cut", released="2026-08-01")
|
|
1044
|
+
e = _epic(v, "an-epic")
|
|
1045
|
+
_task(e / "complete", "shipped-thing")
|
|
1046
|
+
os.environ["WORK_DIR"] = tmp
|
|
1047
|
+
try:
|
|
1048
|
+
out = io.StringIO()
|
|
1049
|
+
with contextlib.redirect_stdout(out):
|
|
1050
|
+
version.cmd_archive({"name": "26-cut", "dry-run": "true"})
|
|
1051
|
+
finally:
|
|
1052
|
+
os.environ.pop("WORK_DIR")
|
|
1053
|
+
text = out.getvalue()
|
|
1054
|
+
assert "versions/complete/26-cut" in text, text
|
|
1055
|
+
assert "versions/archive/26-cut" not in text, text
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def test_the_board_accounts_for_a_shipped_cut_in_one_line():
|
|
1059
|
+
# A cut that shipped leaves the live board — that is what keeps `list`
|
|
1060
|
+
# readable — but leaving it out ENTIRELY made a repo with a release behind it
|
|
1061
|
+
# read exactly like one that has never shipped anything. So both readers keep
|
|
1062
|
+
# one line for it: what it was, when it shipped, what it delivered.
|
|
1063
|
+
import io, os, contextlib
|
|
1064
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1065
|
+
root = Path(tmp)
|
|
1066
|
+
v = _tree(tmp, "26-cut", outcome="a user can bounce a mix",
|
|
1067
|
+
released="2026-08-01")
|
|
1068
|
+
e = _epic(v, "an-epic")
|
|
1069
|
+
_task(e / "complete", "shipped-thing")
|
|
1070
|
+
os.environ["WORK_DIR"] = tmp
|
|
1071
|
+
try:
|
|
1072
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
1073
|
+
version.cmd_archive({"name": "26-cut"})
|
|
1074
|
+
out = io.StringIO()
|
|
1075
|
+
with contextlib.redirect_stdout(out):
|
|
1076
|
+
report.cmd_list({})
|
|
1077
|
+
finally:
|
|
1078
|
+
os.environ.pop("WORK_DIR")
|
|
1079
|
+
|
|
1080
|
+
listed = out.getvalue()
|
|
1081
|
+
assert "SHIPPED 26-cut" in listed, listed
|
|
1082
|
+
assert "released 2026-08-01" in listed, listed
|
|
1083
|
+
assert "a user can bounce a mix" in listed, listed
|
|
1084
|
+
assert "1/1" in listed, listed
|
|
1085
|
+
# ONE LINE, not the cut's task rows — the tasks stay in the record.
|
|
1086
|
+
assert "shipped-thing" not in listed, listed
|
|
1087
|
+
|
|
1088
|
+
readme = (root / "README.md").read_text()
|
|
1089
|
+
assert "### Shipped" in readme, readme
|
|
1090
|
+
assert "versions/complete/26-cut/version.md" in readme, readme
|
|
1091
|
+
assert "released 2026-08-01" in readme, readme
|
|
1092
|
+
assert "a user can bounce a mix" in readme, readme
|
|
1093
|
+
assert "shipped-thing" not in readme, readme
|
|
1094
|
+
|
|
1095
|
+
|
|
998
1096
|
def test_epic_covers_must_be_feature_qualified():
|
|
999
1097
|
# A bare AC-01 would name a different criterion in each feature an epic
|
|
1000
1098
|
# spans, so the format check is a refusal at write time.
|
package/harness/work.py
CHANGED
|
@@ -17,6 +17,7 @@ ledger: a durable rule lives in the domain or system that owns it, and
|
|
|
17
17
|
│ └── <epic>/epic.md the plan-it-together doc; removed at release
|
|
18
18
|
│ └── {queue,in-progress,blocked,complete}/<task>/
|
|
19
19
|
├── backlog/<epic>/<task>/ epics planned but not yet in a cut
|
|
20
|
+
├── complete/<cut>/ a cut that SHIPPED — released, outcome met
|
|
20
21
|
└── archive/<cut>/ a cut taken off the board unreleased
|
|
21
22
|
|
|
22
23
|
A VERSION is a release: it states a user-visible `outcome:`, and its folder is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.80",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -57,15 +57,15 @@
|
|
|
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",
|
|
61
|
+
"@jarvis/anthropic": "1.0.0",
|
|
62
62
|
"@jarvis/data": "0.1.0",
|
|
63
63
|
"@jarvis/errors": "1.0.0",
|
|
64
64
|
"@jarvis/logger": "1.0.0",
|
|
65
65
|
"@jarvis/rpc": "1.0.0",
|
|
66
66
|
"@jarvis/types": "1.0.0",
|
|
67
|
-
"@jarvis/typescript-config": "1.0.0",
|
|
68
67
|
"@jarvis/ui": "0.1.0",
|
|
68
|
+
"@jarvis/typescript-config": "1.0.0",
|
|
69
69
|
"@jarvis/vitest-config": "1.0.0"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|