@appchy/jarvis 0.1.84 → 0.1.86

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.
@@ -6203,6 +6203,740 @@ def test_migrate_refuses_a_repo_holding_two_backlogs():
6203
6203
  assert (Path(tmp) / "backlog").is_dir(), "it moved something before refusing"
6204
6204
 
6205
6205
 
6206
+
6207
+ def test_an_epic_whose_plan_doc_is_gone_still_moves_into_a_cut():
6208
+ # `release` removes every `epic.md`, so an epic promoted out of a released cut
6209
+ # arrives with no plan doc to stamp. Reading it anyway raised AFTER the folders
6210
+ # had already moved — a half-moved tree, and no CLI command that could put it
6211
+ # back.
6212
+ with tempfile.TemporaryDirectory() as tmp:
6213
+ root = Path(tmp)
6214
+ v = _tree(tmp, "26-cut", released="2026-08-01")
6215
+ (root / "versions" / "27-next").mkdir(parents=True)
6216
+ (root / "versions" / "27-next" / "version.md").write_text(
6217
+ "---\ncreated: 2026-08-01\norder: 27\noutcome: y\n---\n\n# Next\n")
6218
+ e = _epic(v, "no-plan-left")
6219
+ (e / "queue").mkdir()
6220
+ _task(e / "queue", "still-to-do")
6221
+ (e / "epic.md").unlink() # what release leaves behind
6222
+
6223
+ os.environ["WORK_DIR"] = tmp
6224
+ try:
6225
+ epic.cmd_epic_move({"name": "no-plan-left", "version": "27-next"})
6226
+ finally:
6227
+ os.environ.pop("WORK_DIR", None)
6228
+
6229
+ dest = root / "versions" / "27-next" / "no-plan-left"
6230
+ assert (dest / "queue" / "still-to-do" / "task.md").is_file()
6231
+ assert not (v / "no-plan-left").exists(), "the source was left behind"
6232
+
6233
+
6234
+ def test_releasing_a_cut_holding_an_epic_with_no_plan_doc_still_releases_it():
6235
+ # The unlink ran on every epic, so one without a plan doc raised — and it raised
6236
+ # AFTER `released:` was stamped, leaving the cut reading as released while every
6237
+ # other epic kept the file release exists to remove.
6238
+ with tempfile.TemporaryDirectory() as tmp:
6239
+ root = Path(tmp)
6240
+ v = _tree(tmp)
6241
+ planned = _epic(v, "publish-under-your-name")
6242
+ _task(planned / "complete", "author-a-lesson")
6243
+ bare = _epic(v, "no-plan-left")
6244
+ _task(bare / "complete", "shipped-already")
6245
+ (bare / "epic.md").unlink()
6246
+
6247
+ os.environ["WORK_DIR"] = tmp
6248
+ try:
6249
+ version.cmd_release({"name": "26-cut"})
6250
+ finally:
6251
+ os.environ.pop("WORK_DIR", None)
6252
+
6253
+ after = model.locate_version(root, "26-cut")
6254
+ assert after.released, "the cut never got its released stamp"
6255
+ assert not (after.folder / "publish-under-your-name" / "epic.md").exists() \
6256
+ and not (after.folder / "complete" / "publish-under-your-name" / "epic.md").exists(), \
6257
+ "a plan doc survived the release"
6258
+
6259
+
6260
+ def test_a_shipped_criterion_delivered_by_a_filed_cut_is_still_covered():
6261
+ # Filing a cut used to turn every AC it delivered into a warning nothing could
6262
+ # ever satisfy: the rollup read the live board only, and a shipped cut is not on
6263
+ # it. The work did not stop being the evidence when the cut left the board.
6264
+ with tempfile.TemporaryDirectory() as tmp:
6265
+ root = Path(tmp) / "work"
6266
+ (root / "product").mkdir(parents=True)
6267
+ (root / "product" / "search.md").write_text(
6268
+ "---\ntype: feature\nstate: shipped\n---\n\n"
6269
+ "## Acceptance criteria\n- [x] AC-01 (unit): a\n")
6270
+ filed = root / "versions" / "complete" / "26-cut"
6271
+ filed.mkdir(parents=True)
6272
+ (filed / "version.md").write_text(
6273
+ "---\ncreated: 2026-08-01\norder: 26\noutcome: x\n"
6274
+ "released: 2026-09-01\n---\n\n# Cut\n")
6275
+ e = _epic(filed, "an-epic")
6276
+ _task(e / "complete", "did-the-work",
6277
+ fm="priority: P1\nowner: search\ncovers: [AC-01]\n")
6278
+
6279
+ assert lint._coverage_lint(root, model.scan(root)) == []
6280
+
6281
+
6282
+ def test_the_config_comes_from_the_repo_whose_tree_is_being_written():
6283
+ # Both variables set at once is the ordinary Claude Code case, and the two
6284
+ # searches read them in opposite orders: the tree came from WORK_DIR while the
6285
+ # config came from CLAUDE_PROJECT_DIR. So a command mutated one repo's board
6286
+ # while every id, standard and gate ran in another repo's dialect.
6287
+ with tempfile.TemporaryDirectory() as tmp:
6288
+ holds_the_tree = Path(tmp) / "b"
6289
+ (holds_the_tree / "work" / "versions").mkdir(parents=True)
6290
+ _repo_with_config(str(holds_the_tree), {"ids": {"prefix": "BEE"}})
6291
+ elsewhere = Path(tmp) / "a"
6292
+ (elsewhere / "work" / "versions").mkdir(parents=True)
6293
+ _repo_with_config(str(elsewhere), {"ids": {"prefix": "AY"}})
6294
+
6295
+ was = {k: os.environ.get(k) for k in ("CLAUDE_PROJECT_DIR", "WORK_DIR")}
6296
+ os.environ["WORK_DIR"] = str(holds_the_tree / "work")
6297
+ os.environ["CLAUDE_PROJECT_DIR"] = str(elsewhere)
6298
+ try:
6299
+ assert entry._project_root({}) == holds_the_tree.resolve()
6300
+ assert config.load(entry._project_root({}))["ids"]["prefix"] == "BEE", \
6301
+ "config resolved from a repo other than the one holding the tree"
6302
+ assert tree.find_work_root().parent == holds_the_tree.resolve(), \
6303
+ "fixture: the tree search did not read WORK_DIR"
6304
+ finally:
6305
+ for k, v in was.items():
6306
+ os.environ.pop(k, None)
6307
+ if v is not None:
6308
+ os.environ[k] = v
6309
+
6310
+
6311
+ def test_a_refused_completion_is_not_told_its_work_is_in_somebody_elses_commit():
6312
+ # A refusal changed nothing, so there is nothing for a commit to carry. It was
6313
+ # buffered like any other event, so the seam saw work waiting with an untouched
6314
+ # tree and reported the change as "carried by a board write that committed a
6315
+ # moment earlier" — telling a session its work was safely in git under another
6316
+ # item's commit, when nothing had been written and nothing committed.
6317
+ with tempfile.TemporaryDirectory() as tmp:
6318
+ try:
6319
+ repo = _git_repo(tmp, push=False)
6320
+ e = _provable_task(repo)
6321
+ events._PENDING.clear()
6322
+ with _work_dir(str(repo / "work")):
6323
+ assert task.cmd_move({"name": "alpha", "status": "complete",
6324
+ "delivered": "it ships",
6325
+ "not-included": "nothing"}) == 1, \
6326
+ "fixture: the gate was supposed to refuse"
6327
+ assert (e / "in-progress" / "alpha").is_dir()
6328
+
6329
+ committed, pushed, note = _board_write(repo, "alpha", events.pending())
6330
+ assert note == "", f"a refusal was told: {note}"
6331
+ assert not committed
6332
+ assert events.pending() == [], "a refusal is queued for a commit"
6333
+ finally:
6334
+ events._PENDING.clear()
6335
+ config.apply(config.DEFAULTS)
6336
+
6337
+
6338
+ def _item_on_at(repo, ref, name, when):
6339
+ """`_item_on`, with the commit stamped at a given instant-with-offset. Git reads
6340
+ both dates from the environment, and they have to move together: one sort reads
6341
+ the committer date and the other the author date."""
6342
+ was = {k: os.environ.get(k) for k in ("GIT_AUTHOR_DATE", "GIT_COMMITTER_DATE")}
6343
+ os.environ["GIT_AUTHOR_DATE"] = when
6344
+ os.environ["GIT_COMMITTER_DATE"] = when
6345
+ try:
6346
+ return _item_on(repo, ref, "an-epic", "in-progress", name)
6347
+ finally:
6348
+ for k, v in was.items():
6349
+ os.environ.pop(k, None)
6350
+ if v is not None:
6351
+ os.environ[k] = v
6352
+
6353
+
6354
+ def test_a_ref_from_another_timezone_does_not_present_as_the_newest():
6355
+ # Both sorts compared strict ISO stamps AS TEXT, and each carries its author's
6356
+ # own offset — so 10:00+03:00 sorted above 09:00+00:00 while actually being two
6357
+ # hours EARLIER. A colleague one timezone east made every ref of theirs look
6358
+ # like the most recent one, and `at` then read the wrong branch as current.
6359
+ with tempfile.TemporaryDirectory() as tmp:
6360
+ repo = _git_repo(tmp, push=False)
6361
+ _item_on_at(repo, "east", "one-thing", "2026-09-01T10:00:00+03:00") # 07:00Z
6362
+ _item_on_at(repo, "west", "one-thing", "2026-09-01T09:00:00+00:00") # 09:00Z
6363
+
6364
+ order = [r["ref"] for r in branches.refs_state(repo)["refs"]]
6365
+ assert order.index("west") < order.index("east"), \
6366
+ f"the later commit is not first: {order}"
6367
+
6368
+ found = [b["branch"] for b in branches.branches_of(repo, "one-thing")]
6369
+ assert found[0] == "west", f"the later commit is not first: {found}"
6370
+
6371
+
6372
+ def _coverage_fixture(tmp: str, body: str, shard: dict = None) -> Path:
6373
+ """A work root with one feature and, optionally, one runner's shard."""
6374
+ root = Path(tmp) / "work"
6375
+ (root / "product").mkdir(parents=True)
6376
+ (root / "product" / "viewer.md").write_text(
6377
+ "---\ntype: feature\nstate: building\n---\n\n## Acceptance criteria\n" + body)
6378
+ if shard is not None:
6379
+ src = root.parent / "pkg" / "x.test.ts"
6380
+ src.parent.mkdir(parents=True, exist_ok=True)
6381
+ src.write_text("//\n")
6382
+ d = root.parent / ".work" / "coverage"
6383
+ d.mkdir(parents=True)
6384
+ (d / "vitest.json").write_text(json.dumps(
6385
+ {"source": "pkg/x.test.ts", "runner": "vitest", "covered": shard}))
6386
+ return root
6387
+
6388
+
6389
+ def _coverage_report(root: Path) -> str:
6390
+ import contextlib, io
6391
+ buf = io.StringIO()
6392
+ with _work_dir(str(root)):
6393
+ with contextlib.redirect_stdout(buf):
6394
+ assert coverage.cmd_coverage({}) == 0
6395
+ return buf.getvalue()
6396
+
6397
+
6398
+ def test_a_criterion_only_a_person_can_settle_is_not_reported_as_unbuilt():
6399
+ # Eyes-on comes OUT of the ratio, which the denominator already did. "Not built
6400
+ # yet" was then derived by subtracting that denominator from everything
6401
+ # declared — so every criterion a person had already looked at came back
6402
+ # reported as behaviour nobody has written.
6403
+ with tempfile.TemporaryDirectory() as tmp:
6404
+ root = _coverage_fixture(
6405
+ tmp,
6406
+ "- [x] AC-01 (unit): a thing a test proves\n"
6407
+ "- [x] AC-02 (eyes-on): a thing only a person can settle\n"
6408
+ "- [ ] AC-03 (unit): nobody has written this\n",
6409
+ {"viewer/AC-01": {"status": "passed"}})
6410
+ out = _coverage_report(root)
6411
+ assert "Not built yet: 1 of 3" in out, out
6412
+ assert "Settled by eyes, not by a run: 1" in out, out
6413
+
6414
+
6415
+ def test_the_built_column_counts_what_the_ratio_divides_by():
6416
+ # The column showed every built criterion while the ratio divided by the
6417
+ # run-provable ones, so a feature with an eyes-on criterion printed a table that
6418
+ # disagreed with the headline directly under it.
6419
+ with tempfile.TemporaryDirectory() as tmp:
6420
+ root = _coverage_fixture(
6421
+ tmp,
6422
+ "- [x] AC-01 (unit): a thing a test proves\n"
6423
+ "- [x] AC-02 (eyes-on): a thing only a person can settle\n",
6424
+ {"viewer/AC-01": {"status": "passed"}})
6425
+ out = _coverage_report(root)
6426
+ row = next(l for l in out.splitlines() if l.strip().startswith("viewer"))
6427
+ built = int(row.split()[1])
6428
+ headline = next(l for l in out.splitlines() if "BUILT COVERAGE" in l)
6429
+ assert f"{built}/{built}" in headline, f"{row!r} disagrees with {headline!r}"
6430
+ assert built == 1, row
6431
+
6432
+
6433
+ def _git_repo_cli(tmp):
6434
+ """`_git_repo`, with git mode written into the repo's own config file so the
6435
+ ENTRY resolves it — driving `main()` re-reads that file, and a fixture that only
6436
+ called `config.apply` would have git switched off by the time the command ran."""
6437
+ repo = _git_repo(tmp, push=False)
6438
+ (repo / ".claude" / "work.config.json").write_text(json.dumps(
6439
+ {"git": {"commit": True, "push": False, "remote": "origin",
6440
+ "paths": ["work"]}}))
6441
+ _git(repo, "add", "-A")
6442
+ _git(repo, "commit", "-qm", "git mode on")
6443
+ return repo
6444
+
6445
+
6446
+ def _entry(repo, *argv):
6447
+ """One command through the entry point, against `repo`. Returns its exit code,
6448
+ or the SystemExit's — a refusal is an answer, and it still must not write."""
6449
+ import contextlib, io
6450
+ was = sys.argv
6451
+ sys.argv = ["work.py", *argv]
6452
+ buf = io.StringIO()
6453
+ try:
6454
+ with _work_dir(str(repo / "work")):
6455
+ with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
6456
+ try:
6457
+ return entry.main()
6458
+ except SystemExit as e:
6459
+ return e.code
6460
+ finally:
6461
+ sys.argv = was
6462
+
6463
+
6464
+ def test_a_command_that_only_looks_never_commits_the_board():
6465
+ # The commit used to sit in a `finally:` every command drove, so `list`,
6466
+ # `status`, `where` and `context` all landed whatever the person happened to
6467
+ # have open in work/ at that moment — as "docs(work): board edits", under no
6468
+ # item, at a moment nobody chose.
6469
+ with tempfile.TemporaryDirectory() as tmp:
6470
+ try:
6471
+ repo = _git_repo_cli(tmp)
6472
+ (repo / "work" / "product" / "README.md").write_text("a hand edit\n")
6473
+ before = _git(repo, "rev-parse", "HEAD").stdout.strip()
6474
+
6475
+ for verb in ("list", "status", "align", "context", "remind"):
6476
+ _entry(repo, verb)
6477
+ assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before, \
6478
+ f"`{verb}` committed the board"
6479
+
6480
+ assert "work/product/README.md" in _git(repo, "status", "--porcelain").stdout
6481
+ finally:
6482
+ config.apply(config.DEFAULTS)
6483
+
6484
+
6485
+ def test_a_command_that_writes_the_board_still_commits_it():
6486
+ # The other half of the same rule, and the one that must never regress: a write
6487
+ # is not done until it is in git.
6488
+ with tempfile.TemporaryDirectory() as tmp:
6489
+ try:
6490
+ repo = _git_repo_cli(tmp)
6491
+ v = repo / "work" / "versions" / "26-cut"
6492
+ v.mkdir(parents=True)
6493
+ (v / "version.md").write_text(
6494
+ "---\ncreated: 2026-08-01\norder: 26\noutcome: x\n---\n\n# Cut\n")
6495
+ _epic(v, "an-epic")
6496
+ _git(repo, "add", "-A")
6497
+ _git(repo, "commit", "-qm", "a cut to work in")
6498
+
6499
+ assert _entry(repo, "new", "a-thing", "--epic", "an-epic") == 0
6500
+ landed = _git(repo, "log", "-1", "--format=%s%n%b").stdout
6501
+ assert "a-thing" in landed, landed
6502
+ assert "Work-Item: a-thing" in landed, landed
6503
+ assert _git(repo, "status", "--porcelain").stdout.strip() == "", \
6504
+ "the scaffold is still sitting uncommitted"
6505
+ finally:
6506
+ config.apply(config.DEFAULTS)
6507
+
6508
+
6509
+ def test_every_command_outside_the_write_set_leaves_the_tree_alone():
6510
+ # What keeps the set above honest. A command that mutates the board and is not
6511
+ # in `git.WRITES` now writes the tree and never lands it, which is worse than
6512
+ # what this replaced — so the guard is not "reads do not commit" but "a
6513
+ # non-write does not WRITE".
6514
+ with tempfile.TemporaryDirectory() as tmp:
6515
+ try:
6516
+ repo = _git_repo_cli(tmp)
6517
+ v = repo / "work" / "versions" / "26-cut"
6518
+ v.mkdir(parents=True)
6519
+ (v / "version.md").write_text(
6520
+ "---\ncreated: 2026-08-01\norder: 26\noutcome: x\n---\n\n# Cut\n")
6521
+ e = _epic(v, "an-epic")
6522
+ _task(e / "in-progress", "a-thing", fm="priority: P1\nowner: product\n")
6523
+ _git(repo, "add", "-A")
6524
+ _git(repo, "commit", "-qm", "a board with work on it")
6525
+
6526
+ for verb in sorted(set(entry.SUBCOMMANDS) - set(git.WRITES)):
6527
+ _entry(repo, verb)
6528
+ dirty = _git(repo, "status", "--porcelain").stdout.strip()
6529
+ assert dirty == "", f"`{verb}` changed the tree:\n{dirty}"
6530
+ finally:
6531
+ config.apply(config.DEFAULTS)
6532
+
6533
+
6534
+ def _bare_git_repo(tmp, cfg):
6535
+ """A clone with an origin and a config file, and NO work tree yet — the state
6536
+ `init` and `migrate` are the only two commands that act on."""
6537
+ import subprocess
6538
+ base = Path(tmp)
6539
+ origin, repo = base / "origin.git", base / "repo"
6540
+ subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True)
6541
+ subprocess.run(["git", "clone", "-q", str(origin), str(repo)], check=True,
6542
+ capture_output=True)
6543
+ for k, v in (("user.email", "t@t"), ("user.name", "T")):
6544
+ subprocess.run(["git", "-C", str(repo), "config", k, v], check=True)
6545
+ (repo / ".claude").mkdir(parents=True)
6546
+ (repo / ".claude" / "work.config.json").write_text(json.dumps(cfg))
6547
+ _git(repo, "add", "-A")
6548
+ _git(repo, "commit", "-qm", "a repo with no board")
6549
+ _git(repo, "push", "-q", "-u", "origin", "HEAD:main")
6550
+ return repo
6551
+
6552
+
6553
+ _GIT_ON = {"git": {"commit": True, "push": False, "remote": "origin",
6554
+ "paths": ["work"]}}
6555
+
6556
+
6557
+ def test_scaffolding_a_board_lands_in_git():
6558
+ # `init` writes a whole tree and was not in the write set, so scoping the commit
6559
+ # to that set would have left a fresh board sitting uncommitted — the exact hole
6560
+ # committing on every write exists to close, opened by closing another one.
6561
+ with tempfile.TemporaryDirectory() as tmp:
6562
+ try:
6563
+ repo = _bare_git_repo(tmp, _GIT_ON)
6564
+ assert _entry(repo, "init") == 0
6565
+ assert (repo / "work" / "README.md").is_file(), "fixture: nothing scaffolded"
6566
+ assert _git(repo, "status", "--porcelain").stdout.strip() == "", \
6567
+ "a scaffolded board is sitting uncommitted"
6568
+ assert "work/README.md" in _git(repo, "log", "-1", "--name-only",
6569
+ "--format=").stdout
6570
+ finally:
6571
+ config.apply(config.DEFAULTS)
6572
+
6573
+
6574
+ def test_a_layout_migration_lands_in_git():
6575
+ # Same shape as `init`: `migrate` moves folders and was outside the write set,
6576
+ # so the one command that restructures somebody's whole board would have left it
6577
+ # restructured and uncommitted.
6578
+ with tempfile.TemporaryDirectory() as tmp:
6579
+ try:
6580
+ repo = _bare_git_repo(tmp, _GIT_ON)
6581
+ old = repo / "work" / "backlog" / "an-epic"
6582
+ old.mkdir(parents=True)
6583
+ (old / "epic.md").write_text("---\ntype: epic\n---\n\n# E\n")
6584
+ (repo / "work" / "versions").mkdir(parents=True)
6585
+ (repo / "work" / "README.md").write_text(
6586
+ f"# Work\n\n{tree.BACKLOG_START}\n{tree.BACKLOG_END}\n")
6587
+ _git(repo, "add", "-A")
6588
+ _git(repo, "commit", "-qm", "a board on the old layout")
6589
+
6590
+ assert _entry(repo, "migrate") == 0
6591
+ assert (repo / "work" / "versions" / "backlog" / "an-epic").is_dir(), \
6592
+ "fixture: nothing moved"
6593
+ assert _git(repo, "status", "--porcelain").stdout.strip() == "", \
6594
+ "a migrated board is sitting uncommitted"
6595
+ finally:
6596
+ config.apply(config.DEFAULTS)
6597
+
6598
+
6599
+ def test_a_session_that_scanned_before_the_other_wrote_gets_the_next_id_not_the_same_one():
6600
+ # The lock could not prevent the race it exists for. The slow tree scan happened
6601
+ # OUTSIDE it and the lock came off the moment the heading was written — so a
6602
+ # second session that scanned before the first one wrote, and claimed after it
6603
+ # released, found the number free twice. Two `### D-nn` headings, one number.
6604
+ with tempfile.TemporaryDirectory() as tmp:
6605
+ root = _initialised(tmp)
6606
+ os.environ["WORK_DIR"] = str(root)
6607
+ real = safety._next_free
6608
+ try:
6609
+ safety.cmd_id_new({"host": "quality", "title": "the first one"})
6610
+ stale = real(root) - 1 # what the other session is still holding
6611
+
6612
+ seen = {"n": 0}
6613
+
6614
+ def scanned_earlier(r):
6615
+ """The other session's OUTER scan, taken before the write above. Every
6616
+ later call is the real thing — including the one under the claim."""
6617
+ seen["n"] += 1
6618
+ return stale if seen["n"] == 1 else real(r)
6619
+
6620
+ safety._next_free = scanned_earlier
6621
+ safety.cmd_id_new({"host": "quality", "title": "the second one"})
6622
+ finally:
6623
+ safety._next_free = real
6624
+ os.environ.pop("WORK_DIR", None)
6625
+
6626
+ text = (root / "quality" / "README.md").read_text()
6627
+ claimed = [m.group(1) for m in ids.titled_heading(3).finditer(text)]
6628
+ assert len(claimed) == 2, claimed
6629
+ assert len(set(claimed)) == 2, f"two rules claimed the same id: {claimed}"
6630
+ assert not list((root.parent / ".work" / "ids").glob("*")), \
6631
+ "a lock file leaked — the number is lost forever"
6632
+
6633
+
6634
+ def test_every_event_the_harness_records_is_one_the_record_knows():
6635
+ # The closed list of kinds is the point — a typo'd kind is a line that never
6636
+ # shows up in a digest — and `append` used to enforce it by RAISING, in front of
6637
+ # a user, after the tree had already changed, on a function whose first sentence
6638
+ # promises it never raises. So the check lives here, where a typo is caught by a
6639
+ # run rather than by whoever hit it.
6640
+ called = set()
6641
+ for module in sorted((Path(__file__).parent / "harness").glob("*.py")):
6642
+ called |= set(re.findall(r"""events\.append\(\s*[^,]+,\s*["']([^"']+)["']""",
6643
+ module.read_text()))
6644
+ assert called, "fixture: found no call sites to check"
6645
+ assert not called - set(events.KINDS), \
6646
+ f"recorded but not in KINDS, so it never reaches a digest: {sorted(called - set(events.KINDS))}"
6647
+
6648
+
6649
+ def test_a_claim_nobody_can_read_reads_as_absent_rather_than_raising():
6650
+ # `read_claim` promises an unreadable claim reads as absent, and the comparison
6651
+ # threw rather than the parse: a `.claim` written without a timezone parsed
6652
+ # fine and then could not be compared against an aware now. One malformed file
6653
+ # took down every command that asks who is holding an item.
6654
+ with tempfile.TemporaryDirectory() as tmp:
6655
+ folder = Path(tmp) / "a-task"
6656
+ folder.mkdir()
6657
+ (folder / shift.CLAIM).write_text(json.dumps(
6658
+ {"instance": "someone", "expires": "2099-01-01T00:00:00"}))
6659
+ assert shift.read_claim(folder) is None
6660
+
6661
+
6662
+ def test_the_lowest_tier_the_vocabulary_defines_can_actually_be_set():
6663
+ # `TIERS` and `TIER_MEANING` both define tier 0 and the method's own table
6664
+ # documents it — reversible and local, a typo, a comment, a test name — and it
6665
+ # was refused every time, because the DEFAULT for an ordinary change was being
6666
+ # used as the FLOOR nobody may go under. Two numbers, not one.
6667
+ assert 0 in tree.TIERS and 0 in tree.TIER_MEANING
6668
+ assert autonomy.derive_tier("lesson-authoring", [], "0")[0] == 0
6669
+ # Nothing about autonomy moved: the ceiling is 2, so 0 sits under it as 1 did.
6670
+ assert autonomy.derive_tier("lesson-authoring", [])[0] == 1, \
6671
+ "a task nobody tiered is still an ordinary change"
6672
+ # And the rule this must not weaken: an owner that floors a task at 3 still
6673
+ # refuses every number under it, 0 included.
6674
+ for want in ("0", "1", "2"):
6675
+ try:
6676
+ autonomy.derive_tier("security", [], want)
6677
+ assert False, f"--tier {want} on a security task should refuse"
6678
+ except SystemExit:
6679
+ pass
6680
+
6681
+
6682
+ def test_two_events_of_one_kind_in_a_commit_keep_their_own_detail():
6683
+ # `verify` records one `verified` per gate, so two in a commit is the ordinary
6684
+ # case rather than an exotic one. The payload was keyed on the KIND, so the
6685
+ # second line was dropped and both events came back wearing the first's fields:
6686
+ # a digest reporting one gate's result twice and the other never.
6687
+ with tempfile.TemporaryDirectory() as tmp:
6688
+ try:
6689
+ repo = _git_repo(tmp, push=False)
6690
+ (repo / "work" / "note.md").write_text("something changed\n")
6691
+ committed, _, note = _board_write(repo, "alpha", [
6692
+ {"event": "verified", "name": "alpha", "gate": "tests", "exit": 0},
6693
+ {"event": "verified", "name": "alpha", "gate": "lint", "exit": 1},
6694
+ ])
6695
+ assert committed, note
6696
+
6697
+ back = [e for e in git.read(repo, name="alpha") if e["event"] == "verified"]
6698
+ assert [e.get("gate") for e in back] == ["tests", "lint"], back
6699
+ assert [e.get("exit") for e in back] == ["0", "1"], back
6700
+ finally:
6701
+ config.apply(config.DEFAULTS)
6702
+
6703
+
6704
+ def test_a_repo_that_moves_its_coverage_shard_is_actually_read_there():
6705
+ # `coverage.shard` is defaulted and documented and nothing read it: the reader
6706
+ # hardcoded `.work/coverage`. So a repo that pointed its runners elsewhere got no
6707
+ # error and no effect, and then `coverage` reported "no evidence" for every
6708
+ # criterion a run had just proved.
6709
+ with tempfile.TemporaryDirectory() as tmp:
6710
+ try:
6711
+ root = Path(tmp) / "work"
6712
+ (root / "product").mkdir(parents=True)
6713
+ (root / "product" / "viewer.md").write_text(
6714
+ "---\ntype: feature\nstate: building\n---\n\n"
6715
+ "## Acceptance criteria\n- [x] AC-01 (unit): a thing a test proves\n")
6716
+ src = root.parent / "pkg" / "x.test.ts"
6717
+ src.parent.mkdir(parents=True)
6718
+ src.write_text("//\n")
6719
+ elsewhere = root.parent / "build" / "evidence"
6720
+ elsewhere.mkdir(parents=True)
6721
+ (elsewhere / "vitest.json").write_text(json.dumps(
6722
+ {"source": "pkg/x.test.ts", "runner": "vitest",
6723
+ "covered": {"viewer/AC-01": {"status": "passed"}}}))
6724
+
6725
+ config.apply({**config.DEFAULTS,
6726
+ "coverage": {"shard": "build/evidence"}})
6727
+ out = _coverage_report(root)
6728
+ assert "no run found" not in out, out
6729
+ assert "BUILT COVERAGE 1/1" in out, out
6730
+ finally:
6731
+ config.apply(config.DEFAULTS)
6732
+
6733
+
6734
+ def test_a_coverage_shard_path_may_not_escape_the_repo():
6735
+ # It is joined onto the repo root, so an absolute one silently becomes the whole
6736
+ # answer — and the session-state root is derived from its parent.
6737
+ for bad in ("/tmp/evidence", "../outside", ""):
6738
+ with tempfile.TemporaryDirectory() as tmp:
6739
+ repo = _repo_with_config(tmp, {"coverage": {"shard": bad}})
6740
+ try:
6741
+ config.load(repo)
6742
+ assert False, f"{bad!r} should be refused"
6743
+ except config.ConfigError as e:
6744
+ assert "coverage.shard" in str(e)
6745
+
6746
+
6747
+ def test_a_task_with_an_open_question_can_be_put_back_into_blocked():
6748
+ # The rule is right — a blocked task nobody can unblock is a dead end — and the
6749
+ # test was the wrong one: it asked whether you had just called `ask`, not whether
6750
+ # the item has an unanswered question. So a task showing in `needs` that moment,
6751
+ # carrying its question in its own frontmatter, could not be returned to
6752
+ # `blocked/` once somebody had moved it out. Which is precisely what a session
6753
+ # does when it picks one up and finds the question still unanswered.
6754
+ with tempfile.TemporaryDirectory() as tmp:
6755
+ v = _tree(tmp)
6756
+ e = _epic(v, "an-epic")
6757
+ _task(e / "in-progress", "alpha",
6758
+ fm="priority: P1\nasked:\n - 2026-09-01 founder which way?\n")
6759
+ with _work_dir(tmp) as root:
6760
+ assert task.cmd_move({"name": "alpha", "status": "blocked"}) == 0
6761
+ assert (e / "blocked" / "alpha").is_dir()
6762
+ assert model.locate(root, "alpha").status == "blocked"
6763
+
6764
+
6765
+ def test_a_task_with_nothing_to_unblock_it_still_cannot_be_blocked():
6766
+ # The half that must not weaken: no open question means nobody can ever take it
6767
+ # out again, and the refusal says which command to reach for instead.
6768
+ with tempfile.TemporaryDirectory() as tmp:
6769
+ v = _tree(tmp)
6770
+ e = _epic(v, "an-epic")
6771
+ _task(e / "in-progress", "alpha")
6772
+ _task(e / "in-progress", "beta",
6773
+ fm=("priority: P1\nasked:\n - 2026-09-01 founder which way?\n"
6774
+ " - answered 2026-09-02 that way\n"))
6775
+ with _work_dir(tmp):
6776
+ for name in ("alpha", "beta"):
6777
+ try:
6778
+ task.cmd_move({"name": name, "status": "blocked"})
6779
+ assert False, f"blocking '{name}' should refuse"
6780
+ except SystemExit:
6781
+ pass
6782
+ assert (e / "in-progress" / name).is_dir()
6783
+
6784
+
6785
+ def test_a_push_that_could_not_rebase_says_what_git_actually_complained_about():
6786
+ # End to end, against a real conflict — the strings below are whatever this git
6787
+ # really produces. The reason used to be read off the FIRST line, which is a
6788
+ # fetch banner, and git glues its `error:` onto the end of the progress counter
6789
+ # with no newline. Measured with four board writes racing: three refusals
6790
+ # explaining themselves as "From /tmp/…/origin", "warning: fetch updated the
6791
+ # current branch head.." and "Rebasing (1/4)." — three non-answers.
6792
+ with tempfile.TemporaryDirectory() as tmp:
6793
+ try:
6794
+ repo = _git_repo(tmp, push=True)
6795
+ config.apply({**config.DEFAULTS,
6796
+ "git": {"commit": True, "push": True, "remote": "origin",
6797
+ "paths": ["work"]}})
6798
+ shared = repo / "work" / "shared.md"
6799
+ shared.write_text("one\n")
6800
+ _git(repo, "add", "-A"); _git(repo, "commit", "-qm", "seed")
6801
+ _git(repo, "push", "-q", "origin", "HEAD:main")
6802
+
6803
+ # Somebody else changes the same board file and lands it first.
6804
+ other = repo.parent / "other"
6805
+ import subprocess
6806
+ subprocess.run(["git", "clone", "-q", str(repo.parent / "origin.git"),
6807
+ str(other)], check=True, capture_output=True)
6808
+ for k, v in (("user.email", "t@t"), ("user.name", "T")):
6809
+ _git(other, "config", k, v)
6810
+ (other / "work" / "shared.md").write_text("theirs\n")
6811
+ _git(other, "add", "-A"); _git(other, "commit", "-qm", "theirs")
6812
+ _git(other, "push", "-q", "origin", "HEAD:main")
6813
+
6814
+ # ...and this clone changes it too, so the rebase has a real conflict.
6815
+ shared.write_text("ours\n")
6816
+ committed, pushed, note = _board_write(
6817
+ repo, "an-item", [{"event": "moved", "name": "an-item"}])
6818
+
6819
+ assert committed and not pushed, note
6820
+ assert "could not apply" in note, note
6821
+ for noise in ("From ", "Rebasing (", "fetch updated"):
6822
+ assert noise not in note, f"the reason is git progress output: {note}"
6823
+ assert "sync" in note, "a push that waited must say how to send it"
6824
+ # And nothing is left half-applied for the session to clean up.
6825
+ assert not [p for p in (repo / ".git").iterdir()
6826
+ if "rebase" in p.name.lower()], "a rebase was left in progress"
6827
+ assert shared.read_text() == "ours\n"
6828
+ finally:
6829
+ config.apply(config.DEFAULTS)
6830
+
6831
+
6832
+ def test_four_board_writes_racing_a_rejected_push_lose_nothing():
6833
+ # `_hold` serialises the COMMIT and deliberately not the push: some commands run
6834
+ # a test suite, and a lock held across `verify` would stall every other agent in
6835
+ # the repo for the length of a build. So the recovery a rejected push runs —
6836
+ # `pull --rebase`, `rebase --abort` — happens outside it, and two writers can
6837
+ # interleave.
6838
+ #
6839
+ # What makes that survivable is that nothing in the recovery MOVES anything: the
6840
+ # autostash and the `reset --hard` that used to sit here went on 2026-09-08, and
6841
+ # git declines rather than rebases over a tree that is not clean. So the answer
6842
+ # to interleaving is that the loser waits — measured across four writers, a
6843
+ # moved origin and a real conflict, and repeated: every item lands, the working
6844
+ # tree is as it was, and no rebase is left half-applied.
6845
+ import threading
6846
+ with tempfile.TemporaryDirectory() as tmp:
6847
+ try:
6848
+ repo = _git_repo(tmp, push=True)
6849
+ config.apply({**config.DEFAULTS,
6850
+ "git": {"commit": True, "push": True, "remote": "origin",
6851
+ "paths": ["work"]}})
6852
+ # THE ORIGIN MOVES, so every push below is rejected and every writer
6853
+ # enters the recovery path that runs outside the lock.
6854
+ import subprocess
6855
+ other = repo.parent / "other"
6856
+ subprocess.run(["git", "clone", "-q", str(repo.parent / "origin.git"),
6857
+ str(other)], check=True, capture_output=True)
6858
+ for k, v in (("user.email", "t@t"), ("user.name", "T")):
6859
+ _git(other, "config", k, v)
6860
+ (other / "work" / "theirs.md").write_text("somebody else\n")
6861
+ _git(other, "add", "-A"); _git(other, "commit", "-qm", "their work")
6862
+ _git(other, "push", "-q", "origin", "HEAD:main")
6863
+
6864
+ # …and the session has its own half-written code open the whole time.
6865
+ (repo / "src").mkdir()
6866
+ (repo / "src" / "app.ts").write_text("half-written\n")
6867
+
6868
+ queue = repo / "work" / "versions" / "v" / "e" / "queue"
6869
+ start = threading.Barrier(4)
6870
+
6871
+ def write(n):
6872
+ folder = queue / f"item-{n}"
6873
+ folder.mkdir(parents=True, exist_ok=True)
6874
+ (folder / "task.md").write_text(f"---\npriority: P0\n---\n\n# {n}\n")
6875
+ start.wait()
6876
+ git.land(repo, f"item-{n}", [{"event": "created", "name": f"item-{n}"}])
6877
+
6878
+ threads = [threading.Thread(target=write, args=(n,)) for n in range(4)]
6879
+ [t.start() for t in threads]
6880
+ [t.join() for t in threads]
6881
+
6882
+ listed = _git(repo, "ls-tree", "-r", "--name-only", "HEAD").stdout
6883
+ for n in range(4):
6884
+ assert f"queue/item-{n}/task.md" in listed, \
6885
+ f"item-{n} was written and never committed:\n{listed}"
6886
+ assert "work/theirs.md" in listed, "another writer's file was lost"
6887
+ assert (repo / "src" / "app.ts").read_text() == "half-written\n", \
6888
+ "the session's own edits were moved"
6889
+ assert not [p for p in (repo / ".git").iterdir()
6890
+ if "rebase" in p.name.lower()], "a rebase was left in progress"
6891
+ assert _git(repo, "status", "--porcelain").stdout.strip() == "?? src/", \
6892
+ _git(repo, "status", "--porcelain").stdout
6893
+ finally:
6894
+ config.apply(config.DEFAULTS)
6895
+
6896
+
6897
+ def test_config_unset_with_no_key_says_so_rather_than_throwing():
6898
+ # The repair branch — `unset` may remove a key the schema does not know — took
6899
+ # the empty key for one of those, because `_present` answers True for a path of
6900
+ # no segments. It then indexed the last of zero parts, so the command an
6901
+ # installer shells out to answered with an IndexError traceback.
6902
+ with tempfile.TemporaryDirectory() as tmp:
6903
+ repo = _repo_with_config(tmp, {"ids": {"prefix": "G"}})
6904
+ for key in ("", ".", " "):
6905
+ try:
6906
+ _cli(repo, "config", "unset", key)
6907
+ assert False, f"unset {key!r} should refuse"
6908
+ except SystemExit as e:
6909
+ assert e.code, f"unset {key!r} exited 0"
6910
+ assert json.loads((repo / ".claude" / "work.config.json").read_text()) == \
6911
+ {"ids": {"prefix": "G"}}, "a refused unset changed the file"
6912
+
6913
+
6914
+ def test_asking_where_the_gates_stand_never_commits_the_board():
6915
+ # `verify` writes the result of a run it EXECUTES, and two of its four doors
6916
+ # execute nothing: `--async` starts a detached child that commits its own
6917
+ # result, `--status` only reports. Both were driving a commit that swept up
6918
+ # whatever the person had open in work/, as "docs(work): board edits" under no
6919
+ # item — seen twice on this repo's own board in one session, while polling for a
6920
+ # gate to finish, which is exactly the call a session repeats.
6921
+ with tempfile.TemporaryDirectory() as tmp:
6922
+ try:
6923
+ repo = _git_repo_cli(tmp)
6924
+ (repo / ".claude" / "work.config.json").write_text(json.dumps(
6925
+ {"git": {"commit": True, "push": False, "remote": "origin",
6926
+ "paths": ["work"]},
6927
+ "verify": {"tests": "true"}}))
6928
+ _git(repo, "add", "-A"); _git(repo, "commit", "-qm", "gates configured")
6929
+ (repo / "work" / "product" / "README.md").write_text("a hand edit\n")
6930
+ before = _git(repo, "rev-parse", "HEAD").stdout.strip()
6931
+
6932
+ _entry(repo, "verify", "--task", "alpha", "--status")
6933
+ assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before, \
6934
+ "asking where the gates stand committed the board"
6935
+ assert "work/product/README.md" in _git(repo, "status", "--porcelain").stdout
6936
+ finally:
6937
+ config.apply(config.DEFAULTS)
6938
+
6939
+
6206
6940
  if __name__ == "__main__":
6207
6941
  tests = [v for k, v in sorted(globals().items())
6208
6942
  if k.startswith("test_") and callable(v)]
@@ -6233,4 +6967,4 @@ if __name__ == "__main__":
6233
6967
  fn()
6234
6968
  print(f"ok {fn.__name__}")
6235
6969
  shutil.rmtree(_TMP, ignore_errors=True)
6236
- print(f"\n{len(tests)} passed")
6970
+ print(f"\n{len(tests)} passed")