@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.
- package/dist/bin.js +1 -1
- package/harness/harness/autonomy.py +20 -8
- package/harness/harness/branches.py +20 -7
- package/harness/harness/config.py +31 -3
- package/harness/harness/coverage.py +25 -11
- package/harness/harness/epic.py +22 -6
- package/harness/harness/events.py +18 -4
- package/harness/harness/git.py +68 -26
- package/harness/harness/lint.py +19 -7
- package/harness/harness/report.py +2 -54
- package/harness/harness/safety.py +12 -1
- package/harness/harness/shard.py +7 -1
- package/harness/harness/shift.py +9 -3
- package/harness/harness/task.py +11 -5
- package/harness/schema/work.config.schema.json +1 -1
- package/harness/test_work.py +735 -1
- package/harness/work.py +57 -51
- package/package.json +5 -5
package/harness/test_work.py
CHANGED
|
@@ -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")
|