@appchy/jarvis 0.1.78 → 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.
@@ -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.
@@ -608,6 +608,33 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
608
608
  elif not head and when != date.today().isoformat():
609
609
  reasons.append(f"verify last passed on {when}, not today, and there "
610
610
  f"is no git sha to pin it to — re-run it")
611
+
612
+ # …and that the code ARRIVED, which is the other half and was missing.
613
+ # Everything above reads COMMITTED history, so it proves the code moved
614
+ # FORWARD PAST the evidence and never that it got there at all. A task can
615
+ # be green with its whole implementation in one working tree; three were,
616
+ # for as long as ten days, and the only commits around them were
617
+ # `docs(work):` ones, which look exactly like a healthy board.
618
+ #
619
+ # It refuses rather than warns because the gates RAN AGAINST THE WORKING
620
+ # TREE. If that tree is not what is in git, the evidence and the commit it
621
+ # is pinned to describe different code, and calling that shipped is the
622
+ # false claim this whole file exists to prevent.
623
+ #
624
+ # The harness names the files and stops there: committing somebody's source
625
+ # is a far larger claim on their repo than committing the board, and a board
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 []
628
+ if loose:
629
+ shown = ", ".join(loose[:5])
630
+ more = f" and {len(loose) - 5} more" if len(loose) > 5 else ""
631
+ reasons.append(
632
+ f"{len(loose)} uncommitted change(s) outside the board — the gates "
633
+ f"ran against this working tree, so completing now would record "
634
+ f"shipped for code no commit contains: {shown}{more}. Commit them "
635
+ f"(or stash or gitignore what is not this task's), then re-run "
636
+ f"`jarvis work verify --task {task.name}` — committing moves HEAD, "
637
+ f"so the evidence has to be taken on the tree that shipped.")
611
638
  else:
612
639
  reasons.append("no `verify` commands configured — an unconfigured repo "
613
640
  "cannot prove anything, so nothing in it can complete. Set "
@@ -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
@@ -95,6 +95,47 @@ def enabled() -> bool:
95
95
  return bool(GIT.get("commit"))
96
96
 
97
97
 
98
+ def uncommitted_code(repo) -> list:
99
+ """Everything outside the board that is not in git yet, sorted.
100
+
101
+ The mirror of `changed`, and the completion gate's half of it. `changed` asks
102
+ what a board write would CARRY; this asks what a completion would LEAVE BEHIND.
103
+
104
+ **Why this is accuracy rather than caution.** A verify run executes the
105
+ configured commands against the WORKING TREE, so a pass means "these gates pass
106
+ here, now". It is then recorded against HEAD. If anything outside the board
107
+ differs from HEAD, those two are statements about different trees, and the
108
+ board ends up saying shipped about code no commit contains. Measured three
109
+ times, once on the task whose entire subject was the harness losing what it is
110
+ trusted to keep — it completed with 7/7 gates recorded twice and none of its
111
+ fix in HEAD.
112
+
113
+ Untracked files count, and they are the important half: the lost work's own
114
+ shape was NEW files git had never heard of. Anything gitignored never appears
115
+ here, so a scratch directory somebody has already told git to ignore is not
116
+ this check's business.
117
+
118
+ Claims and the run file are excluded for the reason they are never committed —
119
+ they are one machine's coordination, true for minutes.
120
+ """
121
+ board = [r.rstrip("/") for r in (GIT.get("paths") or [])]
122
+ code, out, _ = _git(repo, "status", "--porcelain", "-z",
123
+ "--untracked-files=all", "--no-renames")
124
+ if code != 0:
125
+ return []
126
+ found = []
127
+ for entry in out.split("\0"):
128
+ if len(entry) <= 3:
129
+ continue
130
+ p = entry[3:]
131
+ if Path(p).name in LOCAL:
132
+ continue
133
+ if any(p == r or p.startswith(f"{r}/") for r in board):
134
+ continue
135
+ found.append(p)
136
+ return sorted(found)
137
+
138
+
98
139
  def machine() -> str:
99
140
  """Which machine acted. `WORK_MACHINE` where something knows a better name for
100
141
  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.
@@ -3444,6 +3542,145 @@ def test_a_board_write_reaches_the_ORIGIN_not_just_the_disk():
3444
3542
  config.apply(config.DEFAULTS)
3445
3543
 
3446
3544
 
3545
+ def _provable_task(repo, name="alpha"):
3546
+ """A task in a git-mode repo with everything but the code committed: criteria
3547
+ ticked and a passing verify recorded, so the ONLY thing a gate can hold on is
3548
+ whether the implementation reached git."""
3549
+ e = repo / "work" / "versions" / "01-a-cut" / "an-epic"
3550
+ (e / "in-progress" / name).mkdir(parents=True)
3551
+ (e / "epic.md").write_text("---\ntype: epic\n---\n\n# E\n")
3552
+ (e.parent / "version.md").write_text(
3553
+ "---\ncreated: 2026-08-01\norder: 1\noutcome: x\n---\n\n# Cut\n")
3554
+ (e / "in-progress" / name / "task.md").write_text(
3555
+ f"---\npriority: P0\n---\n\n# {name}\n\n## Acceptance criteria\n\n- [x] it works\n")
3556
+ _git(repo, "add", "-A")
3557
+ _git(repo, "commit", "-qm", "the board so far")
3558
+ return e
3559
+
3560
+
3561
+ def test_completing_refuses_while_the_code_is_only_in_the_working_tree():
3562
+ # Every other check in the gate reads COMMITTED history, so all of them prove
3563
+ # the code moved FORWARD PAST the evidence and none proves it arrived. Three
3564
+ # tasks completed green with their whole implementation in one working tree —
3565
+ # one of them the task about the harness losing what it is trusted to keep,
3566
+ # with 7/7 recorded twice and none of its fix in HEAD.
3567
+ with tempfile.TemporaryDirectory() as tmp:
3568
+ try:
3569
+ repo = _git_repo(tmp, push=False)
3570
+ e = _provable_task(repo)
3571
+ with _work_dir(str(repo / "work")) as root:
3572
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3573
+ try:
3574
+ assert gate.cmd_verify({"task": "alpha"}) == 0
3575
+ # Clean tree: nothing left to hold, so the gate is satisfied.
3576
+ assert gate.gate(root, model.locate(root, "alpha")) == []
3577
+
3578
+ # Now the failure this exists for — the implementation exists
3579
+ # and is in no commit. An UNTRACKED file, because that is the
3580
+ # lost work's own shape: new files git had never heard of.
3581
+ (repo / "src").mkdir()
3582
+ (repo / "src" / "shipped.ts").write_text("export const it = 1;\n")
3583
+ reasons = gate.gate(root, model.locate(root, "alpha"))
3584
+ assert any("uncommitted" in r for r in reasons), reasons
3585
+ assert any("src/shipped.ts" in r for r in reasons), reasons
3586
+
3587
+ # And the move itself refuses, leaving the task where it was —
3588
+ # warning after moving is what made the board say done anyway.
3589
+ assert task.cmd_move({"name": "alpha", "status": "complete",
3590
+ "delivered": "it ships",
3591
+ "not-included": "nothing"}) == 1
3592
+ assert (e / "in-progress" / "alpha").is_dir()
3593
+
3594
+ # Committing satisfies THIS check and trips the older one, and
3595
+ # that is the flow rather than a snag: committing moves HEAD, so
3596
+ # evidence taken before it describes a different commit. The two
3597
+ # together mean commit, then verify, then complete — and the
3598
+ # refusal says so rather than leaving it to be discovered.
3599
+ _git(repo, "add", "-A")
3600
+ _git(repo, "commit", "-qm", "the code")
3601
+ reasons = gate.gate(root, model.locate(root, "alpha"))
3602
+ assert not any("uncommitted" in r for r in reasons), reasons
3603
+ assert any("code moved since verify" in r for r in reasons), reasons
3604
+
3605
+ assert gate.cmd_verify({"task": "alpha"}) == 0
3606
+ assert gate.gate(root, model.locate(root, "alpha")) == []
3607
+ finally:
3608
+ gate.VERIFY = old
3609
+ finally:
3610
+ config.apply(config.DEFAULTS)
3611
+
3612
+
3613
+ def test_partly_committed_code_is_still_uncommitted_code():
3614
+ # What `the-hub-says-why-a-write-failed` actually was: part of its work in HEAD
3615
+ # and part not. A check that asked "did anything land" would have passed it.
3616
+ with tempfile.TemporaryDirectory() as tmp:
3617
+ try:
3618
+ repo = _git_repo(tmp, push=False)
3619
+ _provable_task(repo)
3620
+ (repo / "src").mkdir()
3621
+ (repo / "src" / "landed.ts").write_text("export const a = 1;\n")
3622
+ _git(repo, "add", "-A")
3623
+ _git(repo, "commit", "-qm", "half of it")
3624
+ (repo / "src" / "left-behind.ts").write_text("export const b = 2;\n")
3625
+ with _work_dir(str(repo / "work")) as root:
3626
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3627
+ try:
3628
+ assert gate.cmd_verify({"task": "alpha"}) == 0
3629
+ reasons = gate.gate(root, model.locate(root, "alpha"))
3630
+ assert any("src/left-behind.ts" in r for r in reasons), reasons
3631
+ finally:
3632
+ gate.VERIFY = old
3633
+ finally:
3634
+ config.apply(config.DEFAULTS)
3635
+
3636
+
3637
+ def test_a_repo_that_never_asked_for_git_completes_exactly_as_before():
3638
+ # The harness is shared with repos that opted out of all of this. Refusing on
3639
+ # their working tree would be a new claim on somebody who asked for none.
3640
+ with tempfile.TemporaryDirectory() as tmp:
3641
+ try:
3642
+ repo = _git_repo(tmp, push=False)
3643
+ _provable_task(repo)
3644
+ (repo / "src").mkdir()
3645
+ (repo / "src" / "shipped.ts").write_text("export const it = 1;\n")
3646
+ config.apply({**config.DEFAULTS,
3647
+ "git": {"commit": False, "push": False,
3648
+ "remote": "origin", "paths": ["work"]}})
3649
+ with _work_dir(str(repo / "work")) as root:
3650
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3651
+ try:
3652
+ assert gate.cmd_verify({"task": "alpha"}) == 0
3653
+ assert gate.gate(root, model.locate(root, "alpha")) == []
3654
+ finally:
3655
+ gate.VERIFY = old
3656
+ finally:
3657
+ config.apply(config.DEFAULTS)
3658
+
3659
+
3660
+ def test_a_gitignored_scratch_file_is_not_this_gates_business():
3661
+ # The cost of refusing is that loose files block completion, and the escape is
3662
+ # the one people already use. A file git has been told to ignore never reaches
3663
+ # the check at all.
3664
+ with tempfile.TemporaryDirectory() as tmp:
3665
+ try:
3666
+ repo = _git_repo(tmp, push=False)
3667
+ _provable_task(repo)
3668
+ (repo / ".gitignore").write_text("scratch/\n")
3669
+ _git(repo, "add", "-A")
3670
+ _git(repo, "commit", "-qm", "ignore scratch")
3671
+ (repo / "scratch").mkdir()
3672
+ (repo / "scratch" / "notes.md").write_text("thinking out loud\n")
3673
+ with _work_dir(str(repo / "work")) as root:
3674
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3675
+ try:
3676
+ assert gate.cmd_verify({"task": "alpha"}) == 0
3677
+ assert gate.gate(root, model.locate(root, "alpha")) == []
3678
+ finally:
3679
+ gate.VERIFY = old
3680
+ finally:
3681
+ config.apply(config.DEFAULTS)
3682
+
3683
+
3447
3684
  def test_the_commit_trailer_names_the_item_the_event_and_the_machine():
3448
3685
  # Four other pieces of work read this format, so it is asserted rather than
3449
3686
  # assumed. Who, when and which branch stay OUT of it — git knows them, and a