@appchy/jarvis 0.1.124 → 0.1.126
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +14 -4
- package/dist/bin.js.map +1 -1
- package/harness/harness/events.py +3 -1
- package/harness/harness/frontmatter.py +1 -1
- package/harness/harness/model.py +48 -1
- package/harness/harness/report.py +43 -2
- package/harness/harness/task.py +50 -18
- package/harness/harness/tree.py +8 -1
- package/harness/harness/version.py +144 -5
- package/harness/presets/appchy/PRESET.md +46 -8
- package/harness/test_work.py +348 -0
- package/harness/work.py +19 -3
- package/package.json +1 -1
package/harness/test_work.py
CHANGED
|
@@ -8536,6 +8536,354 @@ def test_a_tier_zero_item_is_not_asked_to_have_looked():
|
|
|
8536
8536
|
assert check.checked(root, model.locate(root, "trivial"), "some-session")
|
|
8537
8537
|
|
|
8538
8538
|
|
|
8539
|
+
|
|
8540
|
+
# ---------------------------------------------------------------------------
|
|
8541
|
+
# Work we stopped doing — `archive` at the item tier.
|
|
8542
|
+
#
|
|
8543
|
+
# The board could say an item was FINISHED and could not say we decided not to do
|
|
8544
|
+
# it. The only way off the board was completion or deleting the folder by hand,
|
|
8545
|
+
# which the method forbids, so the cheapest legal action was always to leave the
|
|
8546
|
+
# item — and four boards measured on 2026-09-12 are what that looks like after a
|
|
8547
|
+
# month: 62 items had ever left three of them, against 1,290 created.
|
|
8548
|
+
#
|
|
8549
|
+
# `archive` already meant the right thing at the wrong size: it took a whole cut
|
|
8550
|
+
# and refused unless every task in it was complete. So the verb dispatches on what
|
|
8551
|
+
# the name IS, exactly as `place` does, rather than a second word being invented.
|
|
8552
|
+
# ---------------------------------------------------------------------------
|
|
8553
|
+
|
|
8554
|
+
|
|
8555
|
+
def _archived_entries(folder: Path) -> list:
|
|
8556
|
+
return frontmatter.as_list(
|
|
8557
|
+
frontmatter.parse_frontmatter((folder / "task.md").read_text()).get("archived"))
|
|
8558
|
+
|
|
8559
|
+
|
|
8560
|
+
def test_an_archive_reason_survives_a_comma():
|
|
8561
|
+
# The reason is free text somebody typed, and the INLINE list form splits on
|
|
8562
|
+
# commas — so "we tried it, it did not work" came back as two entries with the
|
|
8563
|
+
# date attached to half of it. The same failure `--question` already had, and
|
|
8564
|
+
# the same fix: this key is written as a block list, never inline.
|
|
8565
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8566
|
+
root = Path(tmp)
|
|
8567
|
+
v = _tree(tmp, "26-cut")
|
|
8568
|
+
e = _epic(v, "an-epic")
|
|
8569
|
+
_task(e / "queue", "comma-case")
|
|
8570
|
+
os.environ["WORK_DIR"] = tmp
|
|
8571
|
+
try:
|
|
8572
|
+
version.cmd_archive({"name": "comma-case",
|
|
8573
|
+
"why": "we tried it, it did not work"})
|
|
8574
|
+
finally:
|
|
8575
|
+
os.environ.pop("WORK_DIR")
|
|
8576
|
+
entries = _archived_entries(root / "versions" / "archive" / "comma-case")
|
|
8577
|
+
assert len(entries) == 1, entries
|
|
8578
|
+
assert "we tried it, it did not work" in entries[0], entries
|
|
8579
|
+
|
|
8580
|
+
|
|
8581
|
+
def test_archive_takes_one_item_off_the_board_and_records_why():
|
|
8582
|
+
# THE MISSING STATE. Nothing is deleted and nothing claims the work was done:
|
|
8583
|
+
# what remains says when we stopped and what the reason was, so the same
|
|
8584
|
+
# proposal can be answered with a citation instead of being re-argued.
|
|
8585
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8586
|
+
root = Path(tmp)
|
|
8587
|
+
v = _tree(tmp, "26-cut")
|
|
8588
|
+
e = _epic(v, "an-epic")
|
|
8589
|
+
_task(e / "queue", "a-duplicate")
|
|
8590
|
+
os.environ["WORK_DIR"] = tmp
|
|
8591
|
+
try:
|
|
8592
|
+
assert version.cmd_archive(
|
|
8593
|
+
{"name": "a-duplicate", "why": "duplicate of ten-tasks"}) == 0
|
|
8594
|
+
finally:
|
|
8595
|
+
os.environ.pop("WORK_DIR")
|
|
8596
|
+
|
|
8597
|
+
assert not (e / "queue" / "a-duplicate").exists()
|
|
8598
|
+
dest = root / "versions" / "archive" / "a-duplicate"
|
|
8599
|
+
assert (dest / "task.md").is_file(), "the item's own text survives"
|
|
8600
|
+
|
|
8601
|
+
entries = _archived_entries(dest)
|
|
8602
|
+
assert len(entries) == 1, entries
|
|
8603
|
+
assert "duplicate of ten-tasks" in entries[0], entries
|
|
8604
|
+
assert date.today().isoformat() in entries[0], entries
|
|
8605
|
+
# WHERE it was proposed, so a reader knows what it was part of without
|
|
8606
|
+
# having to go and find the commit that moved it.
|
|
8607
|
+
assert "26-cut/an-epic" in entries[0], entries
|
|
8608
|
+
|
|
8609
|
+
|
|
8610
|
+
def test_an_archived_item_is_off_the_board_but_still_resolves():
|
|
8611
|
+
# The whole difference between this and a delete. `scan` — and therefore
|
|
8612
|
+
# `list`, the README table and every lint — stops seeing it, while `path`
|
|
8613
|
+
# keeps answering, exactly as an archived CUT already does.
|
|
8614
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8615
|
+
root = Path(tmp)
|
|
8616
|
+
v = _tree(tmp, "26-cut")
|
|
8617
|
+
e = _epic(v, "an-epic")
|
|
8618
|
+
_task(e / "queue", "not-doing-this")
|
|
8619
|
+
_task(e / "queue", "still-doing-this")
|
|
8620
|
+
os.environ["WORK_DIR"] = tmp
|
|
8621
|
+
try:
|
|
8622
|
+
version.cmd_archive({"name": "not-doing-this", "why": "wrong idea"})
|
|
8623
|
+
finally:
|
|
8624
|
+
os.environ.pop("WORK_DIR")
|
|
8625
|
+
|
|
8626
|
+
live = [t.name for vv in model.scan(root)["versions"] for t in vv.all_tasks()]
|
|
8627
|
+
assert live == ["still-doing-this"], live
|
|
8628
|
+
# A write cannot reach it; a READ opts in, the same seam a filed cut uses.
|
|
8629
|
+
assert model.locate(root, "not-doing-this") is None
|
|
8630
|
+
found = model.locate(root, "not-doing-this", filed=True)
|
|
8631
|
+
assert found is not None and found.status == "archived"
|
|
8632
|
+
assert found.folder == root / "versions" / "archive" / "not-doing-this"
|
|
8633
|
+
|
|
8634
|
+
|
|
8635
|
+
def test_archive_refuses_an_item_with_no_reason():
|
|
8636
|
+
# An archived item with no WHY is one somebody re-proposes next month, which
|
|
8637
|
+
# is the failure this state exists to end rather than a field to be tidy about.
|
|
8638
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8639
|
+
v = _tree(tmp, "26-cut")
|
|
8640
|
+
e = _epic(v, "an-epic")
|
|
8641
|
+
_task(e / "queue", "no-reason-given")
|
|
8642
|
+
os.environ["WORK_DIR"] = tmp
|
|
8643
|
+
try:
|
|
8644
|
+
version.cmd_archive({"name": "no-reason-given"})
|
|
8645
|
+
raise AssertionError("archived an item without saying why")
|
|
8646
|
+
except SystemExit as ex:
|
|
8647
|
+
assert ex.code == 1
|
|
8648
|
+
finally:
|
|
8649
|
+
os.environ.pop("WORK_DIR")
|
|
8650
|
+
assert (e / "queue" / "no-reason-given").is_dir(), "nothing moved"
|
|
8651
|
+
|
|
8652
|
+
|
|
8653
|
+
def test_archive_refuses_a_completed_item():
|
|
8654
|
+
# A completed task stays in the cut it shipped in — the one thing the board
|
|
8655
|
+
# fixes. A release whose contents can be edited afterwards cannot answer what
|
|
8656
|
+
# it delivered, and archiving one would be exactly that edit.
|
|
8657
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8658
|
+
v = _tree(tmp, "26-cut")
|
|
8659
|
+
e = _epic(v, "an-epic")
|
|
8660
|
+
_task(e / "complete", "already-shipped")
|
|
8661
|
+
os.environ["WORK_DIR"] = tmp
|
|
8662
|
+
try:
|
|
8663
|
+
version.cmd_archive({"name": "already-shipped", "why": "changed my mind"})
|
|
8664
|
+
raise AssertionError("archived work that had already shipped")
|
|
8665
|
+
except SystemExit as ex:
|
|
8666
|
+
assert ex.code == 1
|
|
8667
|
+
finally:
|
|
8668
|
+
os.environ.pop("WORK_DIR")
|
|
8669
|
+
assert (e / "complete" / "already-shipped").is_dir()
|
|
8670
|
+
|
|
8671
|
+
|
|
8672
|
+
def test_archive_refuses_an_item_another_one_still_depends_on():
|
|
8673
|
+
# Dropping something under a dependent leaves that dependent waiting on a name
|
|
8674
|
+
# nothing will ever complete — and `next` skips it forever with "depends on
|
|
8675
|
+
# incomplete", which reads as a queue problem rather than as this.
|
|
8676
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8677
|
+
v = _tree(tmp, "26-cut")
|
|
8678
|
+
e = _epic(v, "an-epic")
|
|
8679
|
+
_task(e / "queue", "the-foundation")
|
|
8680
|
+
_task(e / "queue", "built-on-it",
|
|
8681
|
+
fm="priority: P1\ndepends_on: [the-foundation]\n")
|
|
8682
|
+
os.environ["WORK_DIR"] = tmp
|
|
8683
|
+
try:
|
|
8684
|
+
version.cmd_archive({"name": "the-foundation", "why": "not worth it"})
|
|
8685
|
+
raise AssertionError("archived an item something else waits on")
|
|
8686
|
+
except SystemExit as ex:
|
|
8687
|
+
assert ex.code == 1
|
|
8688
|
+
finally:
|
|
8689
|
+
os.environ.pop("WORK_DIR")
|
|
8690
|
+
assert (e / "queue" / "the-foundation").is_dir()
|
|
8691
|
+
|
|
8692
|
+
|
|
8693
|
+
def test_archiving_an_item_strips_nothing():
|
|
8694
|
+
# The opposite of what archiving a CUT does, and deliberately so. A cut is
|
|
8695
|
+
# archived AFTER its plans have been distilled into the domains that own them;
|
|
8696
|
+
# an item is archived INSTEAD of being done, so the thinking that reached that
|
|
8697
|
+
# decision is the whole of what there is to keep.
|
|
8698
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8699
|
+
root = Path(tmp)
|
|
8700
|
+
v = _tree(tmp, "26-cut")
|
|
8701
|
+
e = _epic(v, "an-epic")
|
|
8702
|
+
t = _task(e / "in-progress", "half-built")
|
|
8703
|
+
(t / "handoff.md").write_text("where it got to\n")
|
|
8704
|
+
(t / "research").mkdir()
|
|
8705
|
+
(t / "research" / "00-report.md").write_text("what we found out\n")
|
|
8706
|
+
os.environ["WORK_DIR"] = tmp
|
|
8707
|
+
try:
|
|
8708
|
+
version.cmd_archive({"name": "half-built", "why": "the approach was wrong"})
|
|
8709
|
+
finally:
|
|
8710
|
+
os.environ.pop("WORK_DIR")
|
|
8711
|
+
|
|
8712
|
+
dest = root / "versions" / "archive" / "half-built"
|
|
8713
|
+
assert (dest / "handoff.md").is_file()
|
|
8714
|
+
assert (dest / "research" / "00-report.md").is_file()
|
|
8715
|
+
|
|
8716
|
+
|
|
8717
|
+
def test_a_backlog_item_can_be_archived():
|
|
8718
|
+
# Where most droppable work actually sits. The backlog is read as intent, so an
|
|
8719
|
+
# item nobody means to do overstates what is coming just as loudly there.
|
|
8720
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8721
|
+
root = Path(tmp)
|
|
8722
|
+
_tree(tmp, "26-cut")
|
|
8723
|
+
be = _epic(root / "versions" / "backlog", "a-backlog-epic")
|
|
8724
|
+
_task(be, "never-mind")
|
|
8725
|
+
os.environ["WORK_DIR"] = tmp
|
|
8726
|
+
try:
|
|
8727
|
+
assert version.cmd_archive({"name": "never-mind", "why": "superseded"}) == 0
|
|
8728
|
+
finally:
|
|
8729
|
+
os.environ.pop("WORK_DIR")
|
|
8730
|
+
assert (root / "versions" / "archive" / "never-mind" / "task.md").is_file()
|
|
8731
|
+
assert model.scan(root)["backlog"] == []
|
|
8732
|
+
|
|
8733
|
+
|
|
8734
|
+
def test_a_cut_holding_an_archived_item_releases_and_archives():
|
|
8735
|
+
# The item left the cut when it was archived, so a release never sees it —
|
|
8736
|
+
# which is the arithmetic that has to hold or a cut can never close behind one.
|
|
8737
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8738
|
+
root = Path(tmp)
|
|
8739
|
+
v = _tree(tmp, "26-cut")
|
|
8740
|
+
e = _epic(v, "an-epic")
|
|
8741
|
+
_task(e / "complete", "the-work-we-did")
|
|
8742
|
+
_task(e / "queue", "the-work-we-didnt")
|
|
8743
|
+
os.environ["WORK_DIR"] = tmp
|
|
8744
|
+
try:
|
|
8745
|
+
version.cmd_archive({"name": "the-work-we-didnt", "why": "out of scope"})
|
|
8746
|
+
# Every task that is still IN the cut is complete, so this may close.
|
|
8747
|
+
assert version.cmd_release({"name": "26-cut"}) == 0
|
|
8748
|
+
assert version.cmd_archive({"name": "26-cut"}) == 0
|
|
8749
|
+
finally:
|
|
8750
|
+
os.environ.pop("WORK_DIR")
|
|
8751
|
+
|
|
8752
|
+
# The cut shipped, so it files under complete/ …
|
|
8753
|
+
assert (root / "versions" / "complete" / "26-cut" / "version.md").is_file()
|
|
8754
|
+
# … and the archived item is untouched by either, still where it went.
|
|
8755
|
+
assert (root / "versions" / "archive" / "the-work-we-didnt" / "task.md").is_file()
|
|
8756
|
+
|
|
8757
|
+
|
|
8758
|
+
def test_an_archived_item_can_come_back_and_keeps_its_history():
|
|
8759
|
+
# Archived is not deleted, so it is reversible — and putting one back is a
|
|
8760
|
+
# person's deliberate act, which is what keeps the archive read as decisions
|
|
8761
|
+
# rather than as a second queue. The record is APPENDED to, never erased: a
|
|
8762
|
+
# board that can forget it changed its mind cannot be asked why it did.
|
|
8763
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8764
|
+
root = Path(tmp)
|
|
8765
|
+
v = _tree(tmp, "26-cut")
|
|
8766
|
+
e = _epic(v, "an-epic")
|
|
8767
|
+
_task(e / "queue", "worth-doing-after-all")
|
|
8768
|
+
os.environ["WORK_DIR"] = tmp
|
|
8769
|
+
try:
|
|
8770
|
+
version.cmd_archive({"name": "worth-doing-after-all", "why": "too early"})
|
|
8771
|
+
assert task.cmd_place({"name": "worth-doing-after-all",
|
|
8772
|
+
"version": "26-cut", "epic": "an-epic"}) == 0
|
|
8773
|
+
finally:
|
|
8774
|
+
os.environ.pop("WORK_DIR")
|
|
8775
|
+
|
|
8776
|
+
back = e / "queue" / "worth-doing-after-all"
|
|
8777
|
+
assert back.is_dir(), "it lands queued — it has no bucket to keep"
|
|
8778
|
+
assert not (root / "versions" / "archive" / "worth-doing-after-all").exists()
|
|
8779
|
+
entries = _archived_entries(back)
|
|
8780
|
+
assert len(entries) == 2, entries
|
|
8781
|
+
assert "too early" in entries[0], entries
|
|
8782
|
+
assert entries[1].startswith("restored "), entries
|
|
8783
|
+
|
|
8784
|
+
|
|
8785
|
+
def test_archive_still_takes_a_version():
|
|
8786
|
+
# The dispatch must not have cost the tier it already served. Names are unique
|
|
8787
|
+
# across all three tiers, so what the name IS decides which archive runs.
|
|
8788
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8789
|
+
root = Path(tmp)
|
|
8790
|
+
v = _tree(tmp, "26-cut", released="2026-08-01")
|
|
8791
|
+
e = _epic(v, "an-epic")
|
|
8792
|
+
_task(e / "complete", "shipped-thing")
|
|
8793
|
+
os.environ["WORK_DIR"] = tmp
|
|
8794
|
+
try:
|
|
8795
|
+
assert version.cmd_archive({"name": "26-cut"}) == 0
|
|
8796
|
+
finally:
|
|
8797
|
+
os.environ.pop("WORK_DIR")
|
|
8798
|
+
assert (root / "versions" / "complete" / "26-cut" / "version.md").is_file()
|
|
8799
|
+
|
|
8800
|
+
|
|
8801
|
+
def test_archive_of_an_unknown_name_names_both_tiers():
|
|
8802
|
+
import io, contextlib
|
|
8803
|
+
# One verb over two tiers means the refusal has to say what it looked for —
|
|
8804
|
+
# "no version named 'x'" against a mistyped TASK sends the reader to the wrong
|
|
8805
|
+
# question entirely.
|
|
8806
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8807
|
+
_tree(tmp, "26-cut")
|
|
8808
|
+
os.environ["WORK_DIR"] = tmp
|
|
8809
|
+
buf = io.StringIO()
|
|
8810
|
+
try:
|
|
8811
|
+
with contextlib.redirect_stderr(buf):
|
|
8812
|
+
version.cmd_archive({"name": "never-existed", "why": "x"})
|
|
8813
|
+
raise AssertionError("archived a name that resolves to nothing")
|
|
8814
|
+
except SystemExit as ex:
|
|
8815
|
+
assert ex.code == 1
|
|
8816
|
+
finally:
|
|
8817
|
+
os.environ.pop("WORK_DIR")
|
|
8818
|
+
assert "task or version" in buf.getvalue(), buf.getvalue()
|
|
8819
|
+
|
|
8820
|
+
|
|
8821
|
+
def test_archiving_twice_says_where_it_already_went():
|
|
8822
|
+
import io, contextlib
|
|
8823
|
+
# The second attempt is somebody who has lost track of it, not somebody making
|
|
8824
|
+
# a mistake — so it answers the question they actually have.
|
|
8825
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8826
|
+
v = _tree(tmp, "26-cut")
|
|
8827
|
+
e = _epic(v, "an-epic")
|
|
8828
|
+
_task(e / "queue", "gone-already")
|
|
8829
|
+
os.environ["WORK_DIR"] = tmp
|
|
8830
|
+
buf = io.StringIO()
|
|
8831
|
+
try:
|
|
8832
|
+
version.cmd_archive({"name": "gone-already", "why": "no"})
|
|
8833
|
+
with contextlib.redirect_stderr(buf):
|
|
8834
|
+
version.cmd_archive({"name": "gone-already", "why": "still no"})
|
|
8835
|
+
raise AssertionError("archived the same item twice")
|
|
8836
|
+
except SystemExit as ex:
|
|
8837
|
+
assert ex.code == 1
|
|
8838
|
+
finally:
|
|
8839
|
+
os.environ.pop("WORK_DIR")
|
|
8840
|
+
assert "already archived" in buf.getvalue(), buf.getvalue()
|
|
8841
|
+
|
|
8842
|
+
|
|
8843
|
+
def test_list_archived_says_what_was_taken_off_the_board():
|
|
8844
|
+
import io, contextlib
|
|
8845
|
+
# The board deliberately does not show these, so there has to be a door that
|
|
8846
|
+
# does — otherwise the only way to find a decision is to already know its name,
|
|
8847
|
+
# and a record nobody can browse stops the same proposal coming back only by
|
|
8848
|
+
# luck.
|
|
8849
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8850
|
+
v = _tree(tmp, "26-cut")
|
|
8851
|
+
e = _epic(v, "an-epic")
|
|
8852
|
+
_task(e / "queue", "dropped-one")
|
|
8853
|
+
os.environ["WORK_DIR"] = tmp
|
|
8854
|
+
buf = io.StringIO()
|
|
8855
|
+
try:
|
|
8856
|
+
version.cmd_archive({"name": "dropped-one", "why": "overtaken by events"})
|
|
8857
|
+
with contextlib.redirect_stdout(buf):
|
|
8858
|
+
assert report.cmd_list({"archived": "true"}) == 0
|
|
8859
|
+
finally:
|
|
8860
|
+
os.environ.pop("WORK_DIR")
|
|
8861
|
+
out = buf.getvalue()
|
|
8862
|
+
assert "dropped-one" in out, out
|
|
8863
|
+
assert "overtaken by events" in out, out
|
|
8864
|
+
|
|
8865
|
+
|
|
8866
|
+
def test_the_readme_never_shows_an_archived_item():
|
|
8867
|
+
# AC-03 read literally: off the board means off every derived table, and the
|
|
8868
|
+
# README settles in BOTH directions — the row goes when it is archived and
|
|
8869
|
+
# comes back when it is restored.
|
|
8870
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
8871
|
+
root = Path(tmp)
|
|
8872
|
+
v = _tree(tmp, "26-cut")
|
|
8873
|
+
e = _epic(v, "an-epic")
|
|
8874
|
+
_task(e / "queue", "quietly-dropped")
|
|
8875
|
+
os.environ["WORK_DIR"] = tmp
|
|
8876
|
+
try:
|
|
8877
|
+
generate._sync(root)
|
|
8878
|
+
assert "quietly-dropped" in (root / "README.md").read_text()
|
|
8879
|
+
version.cmd_archive({"name": "quietly-dropped", "why": "no longer wanted"})
|
|
8880
|
+
assert "quietly-dropped" not in (root / "README.md").read_text()
|
|
8881
|
+
task.cmd_place({"name": "quietly-dropped", "version": "26-cut",
|
|
8882
|
+
"epic": "an-epic"})
|
|
8883
|
+
assert "quietly-dropped" in (root / "README.md").read_text()
|
|
8884
|
+
finally:
|
|
8885
|
+
os.environ.pop("WORK_DIR")
|
|
8886
|
+
|
|
8539
8887
|
if __name__ == "__main__":
|
|
8540
8888
|
tests = [v for k, v in sorted(globals().items())
|
|
8541
8889
|
if k.startswith("test_") and callable(v)]
|
package/harness/work.py
CHANGED
|
@@ -18,7 +18,9 @@ ledger: a durable rule lives in the domain or system that owns it, and
|
|
|
18
18
|
│ └── {queue,in-progress,blocked,complete}/<task>/
|
|
19
19
|
├── backlog/<epic>/<task>/ epics planned but not yet in a cut
|
|
20
20
|
├── complete/<cut>/ a cut that SHIPPED — released, outcome met
|
|
21
|
-
└── archive
|
|
21
|
+
└── archive/ taken off the board, not deleted
|
|
22
|
+
├── <cut>/ a cut left unreleased — nothing owed on it
|
|
23
|
+
└── <item>/ ONE item we decided not to do
|
|
22
24
|
|
|
23
25
|
A VERSION is a release: it states a user-visible `outcome:`, and its folder is
|
|
24
26
|
named `NN-<name>` because the order is part of the name and nothing derives it.
|
|
@@ -28,7 +30,14 @@ A TASK is ONE goal, end-to-end, internally phased, and always belongs to an epic
|
|
|
28
30
|
A task's status is the bucket it sits in — never a frontmatter field; a backlog
|
|
29
31
|
task has none until pulled, which is why the backlog has no buckets. A version's
|
|
30
32
|
status is derived. Names are globally unique across all three tiers, so a name
|
|
31
|
-
alone resolves anywhere — `place`
|
|
33
|
+
alone resolves anywhere — `place` and `archive` both dispatch on what the name IS.
|
|
34
|
+
|
|
35
|
+
COMPLETE, RELEASED and ARCHIVED are three different words. `complete` is ONE item
|
|
36
|
+
finished; `released` is a whole CUT that met its `outcome:`; `archived` is either
|
|
37
|
+
tier taken OFF the board — a cut with nothing owed on it, or an item we decided not
|
|
38
|
+
to do. Off the board is not gone: `list`, the README and every lint stop seeing it
|
|
39
|
+
while `path` and `where` still resolve it. Deleting a task folder by hand is not a
|
|
40
|
+
third way off the board; `archive <name> --why "…"` is, and it strips nothing.
|
|
32
41
|
|
|
33
42
|
WHAT IS DERIVED, and therefore never written by hand: the `code:` region
|
|
34
43
|
vocabulary (the union of what the systems declare) · work/README.md's table ·
|
|
@@ -50,12 +59,16 @@ Subcommands:
|
|
|
50
59
|
<name> --backlog --epic <e> anywhere, to anywhere
|
|
51
60
|
an EPIC moves whole; a task needs --epic
|
|
52
61
|
move <name> <queue|in-progress|complete>
|
|
62
|
+
archive <name> --why "…" stop doing ONE item — off the board, not deleted.
|
|
63
|
+
Refused with no reason, on completed work, and on
|
|
64
|
+
anything another live item still depends_on
|
|
53
65
|
handoff <name> plan <name> [--file <path>]
|
|
54
66
|
session <name> [--id <uuid>] the current session, or one this one STARTED
|
|
55
67
|
kickoff <name> [--next "…"] [--watch "…"] [--prompt-only]
|
|
56
68
|
the opening prompt for the NEXT session, derived —
|
|
57
69
|
and how to start it where `session.mcp` names a server
|
|
58
70
|
release <v> archive <v> [--dry-run]
|
|
71
|
+
list --archived what we decided not to do, and why
|
|
59
72
|
migrate [--dry-run] [--force] bring work/ up to the layout this harness expects
|
|
60
73
|
init [--project <dir>] scaffold work/ + the nine domains (idempotent)
|
|
61
74
|
doctor [--project <dir>] runtime, config, tree, ids, graph — self-diagnosis
|
|
@@ -481,7 +494,10 @@ def dispatch(cmd, pos, flags, cfg) -> int:
|
|
|
481
494
|
return cmd_release({"name": pos[0]})
|
|
482
495
|
if cmd == "archive":
|
|
483
496
|
if not pos:
|
|
484
|
-
die("usage: jarvis work archive <
|
|
497
|
+
die("usage: jarvis work archive <cut> [--dry-run] "
|
|
498
|
+
"(a whole cut off the board)\n"
|
|
499
|
+
" or: jarvis work archive <item> --why \"…\" "
|
|
500
|
+
"(stop doing one item — off the board, not deleted)")
|
|
485
501
|
return cmd_archive({"name": pos[0], **flags})
|
|
486
502
|
if cmd == "find":
|
|
487
503
|
if not pos:
|