@appchy/jarvis 0.1.79 → 0.1.81

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.
@@ -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
- for d in ("versions", "versions/backlog", "versions/archive"):
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.
@@ -624,7 +624,12 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
624
624
  # The harness names the files and stops there: committing somebody's source
625
625
  # is a far larger claim on their repo than committing the board, and a board
626
626
  # write deliberately leaves a session's own code alone (founder, 2026-09-10).
627
- loose = git.uncommitted_code(root.parent) if git.enabled() else []
627
+ #
628
+ # Scoped to what this task is answerable for, because a checkout is shared
629
+ # and the unscoped version means nobody completes anything until everybody
630
+ # commits. Unclaimed paths, and a task that declares no regions, stay strict.
631
+ loose = (git.uncommitted_mine(root.parent, root, list(task.code or []))
632
+ if git.enabled() else [])
628
633
  if loose:
629
634
  shown = ", ".join(loose[:5])
630
635
  more = f" and {len(loose) - 5} more" if len(loose) > 5 else ""
@@ -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
@@ -136,6 +136,44 @@ def uncommitted_code(repo) -> list:
136
136
  return sorted(found)
137
137
 
138
138
 
139
+ def uncommitted_mine(repo, root, regions: list) -> list:
140
+ """Of the uncommitted code, what THIS task is answerable for.
141
+
142
+ **`moved_only_elsewhere` already decided this question for commits**, and the
143
+ two have to agree. That one says a commit outside your `code:` regions does not
144
+ invalidate your evidence, because a suite failure over there only warns — and
145
+ holding that while letting a merely DIRTY file over there refuse you is the same
146
+ inconsistency, read one step earlier.
147
+
148
+ It is not hypothetical. A session finishing harness work was blocked by a peer's
149
+ 19 uncommitted UI files in a shared checkout, none of them in the region its
150
+ task declared. In a checkout two people share, the unscoped rule means nobody
151
+ can complete anything until everybody commits.
152
+
153
+ **Unclaimed is always yours, and that is the whole safety of it.**
154
+ `system_for_path` answers `None` both for a file nothing declares and for a repo
155
+ whose systems declare nothing, and from here the two are indistinguishable.
156
+ Reading either as *not my business* would make the check vacuous in exactly the
157
+ repos that never filled in `paths:`. A task that declares no `code:` of its own
158
+ is answerable for all of it, for the same reason: it has not said what it
159
+ touches, so it cannot say a file is somebody else's.
160
+ """
161
+ loose = uncommitted_code(repo)
162
+ if not loose or not regions:
163
+ return loose
164
+ from .registry import scan_systems, system_for_path
165
+ systems = scan_systems(root)
166
+ if not systems:
167
+ return loose
168
+ mine = {r for r in regions if r}
169
+ kept = []
170
+ for name in loose:
171
+ owner = system_for_path(root, name, systems)
172
+ if owner is None or (mine & set(owner.code)):
173
+ kept.append(name)
174
+ return kept
175
+
176
+
139
177
  def machine() -> str:
140
178
  """Which machine acted. `WORK_MACHINE` where something knows a better name for
141
179
  this box than its hostname — a daemon that already has an identity for it —
@@ -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
- 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)}")
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
@@ -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 `versions/archive/` too, so a finished
314
- cut stays LOOKUPABLE after it leaves the board — `path` and `where` keep
315
- answering, while `scan` (and therefore `list`, the README and every lint)
316
- sees only live work. Archived is off the board, not gone."""
317
- for base in ("versions", "versions/archive"):
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
- if not s["versions"] and not s["backlog"]:
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:
@@ -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, find_work_root, rel
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 = archive_dir(root) / name
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(archive_dir(root) / name, root)
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
- print(f"archived '{name}': stripped {removed} file(s) to task.md, "
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
@@ -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 test_archive_moves_the_version_off_the_board_but_keeps_it_lookupable():
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", released="2026-08-01")
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.
@@ -3536,6 +3634,95 @@ def test_partly_committed_code_is_still_uncommitted_code():
3536
3634
  config.apply(config.DEFAULTS)
3537
3635
 
3538
3636
 
3637
+ def test_a_peers_uncommitted_work_in_another_system_does_not_block_you():
3638
+ # The mirror of `moved_only_elsewhere`, and it has to agree with it: if a
3639
+ # COMMIT outside your regions cannot invalidate your evidence, a merely DIRTY
3640
+ # file outside them cannot refuse you either. Holding both at once is the
3641
+ # inconsistency, read one step earlier.
3642
+ #
3643
+ # Measured rather than imagined: the session that built the unscoped version
3644
+ # was blocked by a peer's 19 uncommitted UI files, none in the region its task
3645
+ # declared. In a shared checkout that rule means nobody completes anything
3646
+ # until everybody commits.
3647
+ with tempfile.TemporaryDirectory() as tmp:
3648
+ try:
3649
+ repo = _git_repo(tmp, push=False)
3650
+ arch = repo / "work" / "architecture"
3651
+ arch.mkdir(parents=True, exist_ok=True)
3652
+ (arch / "board.md").write_text(
3653
+ "---\ntype: system\ncode: [data]\npaths: [packages/board]\n"
3654
+ "depends_on: []\n---\n\n# Board\n")
3655
+ (arch / "screen.md").write_text(
3656
+ "---\ntype: system\ncode: [web]\npaths: [apps/web]\n"
3657
+ "depends_on: []\n---\n\n# Screen\n")
3658
+ for f in ("packages/board/store.ts", "apps/web/page.tsx", "scripts/loose.py"):
3659
+ (repo / f).parent.mkdir(parents=True, exist_ok=True)
3660
+ (repo / f).write_text("first\n")
3661
+ _provable_task(repo) # commits the board AND the code above
3662
+
3663
+ root = repo / "work"
3664
+ # The task says what it touches, which is what earns it the narrowing.
3665
+ brief = root / "versions" / "01-a-cut" / "an-epic" / "in-progress" / "alpha" / "task.md"
3666
+ brief.write_text(brief.read_text().replace(
3667
+ "priority: P0", "priority: P0\ncode: [data]"))
3668
+ _git(repo, "add", "-A")
3669
+ _git(repo, "commit", "-qm", "alpha declares its region")
3670
+
3671
+ with _work_dir(str(root)) as work_root:
3672
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3673
+ try:
3674
+ assert gate.cmd_verify({"task": "alpha"}) == 0
3675
+
3676
+ # A PEER IS MID-EDIT IN ANOTHER SYSTEM. You can still finish.
3677
+ (repo / "apps/web/page.tsx").write_text("a peer, mid-thought\n")
3678
+ assert gate.gate(work_root, model.locate(work_root, "alpha")) == []
3679
+
3680
+ # YOUR OWN REGION, UNCOMMITTED. That is the thing this catches.
3681
+ (repo / "packages/board/store.ts").write_text("the fix, uncommitted\n")
3682
+ reasons = gate.gate(work_root, model.locate(work_root, "alpha"))
3683
+ assert any("packages/board/store.ts" in r for r in reasons), reasons
3684
+ assert not any("apps/web/page.tsx" in r for r in reasons), reasons
3685
+
3686
+ # A PATH NOTHING CLAIMS IS ALWAYS YOURS. The safety property: a
3687
+ # repo that never filled in `paths:` must not get a gate that
3688
+ # passes everything, which is worse than the noise it replaces.
3689
+ _git(repo, "checkout", "--", "packages/board/store.ts")
3690
+ (repo / "scripts/loose.py").write_text("claimed by no system\n")
3691
+ reasons = gate.gate(work_root, model.locate(work_root, "alpha"))
3692
+ assert any("scripts/loose.py" in r for r in reasons), reasons
3693
+ finally:
3694
+ gate.VERIFY = old
3695
+ finally:
3696
+ config.apply(config.DEFAULTS)
3697
+
3698
+
3699
+ def test_a_task_that_declares_no_region_is_answerable_for_all_of_it():
3700
+ # It has not said what it touches, so it cannot say a file is somebody else's.
3701
+ # The same reading `moved_only_elsewhere` gives an empty `regions`.
3702
+ with tempfile.TemporaryDirectory() as tmp:
3703
+ try:
3704
+ repo = _git_repo(tmp, push=False)
3705
+ arch = repo / "work" / "architecture"
3706
+ arch.mkdir(parents=True, exist_ok=True)
3707
+ (arch / "screen.md").write_text(
3708
+ "---\ntype: system\ncode: [web]\npaths: [apps/web]\n"
3709
+ "depends_on: []\n---\n\n# Screen\n")
3710
+ (repo / "apps/web").mkdir(parents=True)
3711
+ (repo / "apps/web/page.tsx").write_text("first\n")
3712
+ _provable_task(repo)
3713
+ with _work_dir(str(repo / "work")) as root:
3714
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3715
+ try:
3716
+ assert gate.cmd_verify({"task": "alpha"}) == 0
3717
+ (repo / "apps/web/page.tsx").write_text("changed\n")
3718
+ reasons = gate.gate(root, model.locate(root, "alpha"))
3719
+ assert any("apps/web/page.tsx" in r for r in reasons), reasons
3720
+ finally:
3721
+ gate.VERIFY = old
3722
+ finally:
3723
+ config.apply(config.DEFAULTS)
3724
+
3725
+
3539
3726
  def test_a_repo_that_never_asked_for_git_completes_exactly_as_before():
3540
3727
  # The harness is shared with repos that opted out of all of this. Refusing on
3541
3728
  # their working tree would be a new claim on somebody who asked for none.
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.79",
3
+ "version": "0.1.81",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",