@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.
@@ -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/<cut>/ a cut taken off the board unreleased
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` dispatches on what the name IS.
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 <v> [--dry-run]")
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.124",
3
+ "version": "0.1.126",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",