@appchy/jarvis 0.1.113 → 0.1.114

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 CHANGED
@@ -10284,7 +10284,7 @@ import { createRequire as createRequire2 } from "module";
10284
10284
  var _require = createRequire2(import.meta.url);
10285
10285
  var VERSION2 = _require("../package.json").version ?? "0.0.0";
10286
10286
  var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
10287
- var SHA = "e1a824b";
10287
+ var SHA = "3c9f85d";
10288
10288
  var BUILT = "2026-09-12";
10289
10289
  var BUILD = SHA ?? "source";
10290
10290
  var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
@@ -400,14 +400,17 @@ def _align_versions(root: Path, s: dict) -> list:
400
400
  out.append(("warn", "released-brief", f"version {v.name}: "
401
401
  f"released but still carries {brief} — a cut-local "
402
402
  f"brief collapses at release"))
403
- # Same rule, one tier down: an epic is the plan-it-together doc, and
404
- # `release` removes it. One left standing means a release that did not
405
- # go through `jarvis work release`.
403
+ # One tier down the test inverts: a released plan is KEPT, so its
404
+ # presence is correct and what would be wrong is an unstamped one. An
405
+ # epic plan with no released banner means a release that did not go
406
+ # through `jarvis work release` — or a plan added to a cut afterwards,
407
+ # which is the same problem wearing different clothes.
408
+ from .epic import _RELEASED_MARKER
406
409
  for e in v.epics:
407
- if e.planned:
410
+ if e.planned and _RELEASED_MARKER not in e.md.read_text(errors="ignore"):
408
411
  out.append(("warn", "released-brief", f"version {v.name}: "
409
- f"released but epic '{e.name}' still carries "
410
- f"epic.mdan epic archives at release"))
412
+ f"released but epic '{e.name}' carries a plan that is "
413
+ f"not stamped as released it reads as live direction"))
411
414
  return out
412
415
  #: An agent-file reference in skill prose, in either of the two shapes an agent dir
413
416
  #: takes: `.claude/agents/<name>.md` in a repo that keeps its own agents, and a bare
@@ -220,13 +220,19 @@ _DURABLE_SECTIONS = ("## Governance this implies", "## Non-goals", "### Settled"
220
220
  "### Forward-compat")
221
221
 
222
222
 
223
- def removal_cost(version) -> tuple:
224
- """What releasing this cut would delete: `(plans, bytes, sections)`.
223
+ def plans_held(version) -> tuple:
224
+ """What this cut's epic plans hold: `(plans, bytes, sections)`.
225
225
 
226
- One derivation, because two surfaces quote this price now — the notice
227
- `release` prints just before the unlink, and the line `status` shows a person
228
- deciding whether to run it at all. A warning and a confirmation that disagreed
229
- about the cost would make both worth less than either.
226
+ One derivation, because two surfaces quote it — the notice `release` prints as
227
+ it stamps the plans, and the line `status` shows a person deciding whether to
228
+ run it at all. Two surfaces that disagreed about what a cut carries would make
229
+ both worth less than either.
230
+
231
+ It measured what release was about to DELETE until 2026-09-12, when a released
232
+ plan started being kept instead. The number is the same number; what it is
233
+ evidence for is not. It now says how much settled prose is going quiet, which is
234
+ the argument for lifting the durable half into an owner file — not a last call
235
+ before the bytes go.
230
236
 
231
237
  `sections` is every durable heading found across the plans, deduplicated: it is
232
238
  what a reader needs to judge whether the text is safe to lose, and it is the
@@ -245,21 +251,23 @@ def removal_cost(version) -> tuple:
245
251
  return len(plans), total, sorted(held)
246
252
 
247
253
 
248
- def _cost_of_removing(version, root) -> list:
249
- """What this release is about to delete, as lines a person can act on.
254
+ def _cost_of_closing(version, root) -> list:
255
+ """What this release is about to stamp shut, as lines a person can act on.
256
+
257
+ **Said while the plans are still the live thing a reader would reach for.** The
258
+ text no longer leaves the tree — a released plan is stamped and kept — so this is
259
+ not a last call before the bytes go. It is the argument for the sweep: a stamped
260
+ plan is history, and a decision left in one is a decision the next session will
261
+ not find when it reads the owner file.
250
262
 
251
- **Said BEFORE the unlink, because the advice that follows a release is useless
252
- after it.** `release` has always printed _"promote still-load-bearing decisions
253
- to the domain that owns them"_ and printed it after the files holding those
254
- decisions had already left the tree. Measured 2026-09-11 on `01-one-board`: twelve
255
- plans, 450,405 bytes, about a hundred durable statements with no owner file, and
256
- the recovery commit had to be worked out by hand afterwards from `git log
257
- --diff-filter=D`.
263
+ Measured 2026-09-11 on `01-one-board`: twelve plans, 450,405 bytes, about a
264
+ hundred durable statements with no owner file. Those plans were deleted by the
265
+ release that day and restored on 2026-09-12, which is why deletion is no longer
266
+ what this reports.
258
267
 
259
- So this names the size, the sections that can outlive the plan, and the commit
260
- where the text still lives. It does not judge whether a statement is durable
261
- that is the judgement a lint cannot make, and claiming it would be worse than
262
- saying nothing.
268
+ It names the size and the sections that can outlive the plan. It does not judge
269
+ whether a statement is durable that is the judgement a lint cannot make, and
270
+ claiming it would be worse than saying nothing.
263
271
  """
264
272
  plans = [e for e in version.epics if e.planned]
265
273
  if not plans:
@@ -275,57 +283,77 @@ def _cost_of_removing(version, root) -> list:
275
283
  for h in _DURABLE_SECTIONS if h in text]
276
284
  note = f" — holds §{', §'.join(held)}" if held else ""
277
285
  out.append(f" {e.name} {len(text.encode()):,} bytes{note}")
278
- head = _still_holds(root)
279
- return ([f"\n DELETING {len(plans)} epic plan(s), {total:,} bytes. Anything "
280
- f"durable in them must already be in the file that owns it — after this "
281
- f"they are in git only, and nobody reads git for a decision:"]
286
+ return ([f"\n STAMPING {len(plans)} epic plan(s), {total:,} bytes, as released. "
287
+ f"They stay in the tree and every link to them keeps resolving — but a "
288
+ f"stamped plan is history, so anything durable belongs in the file that "
289
+ f"owns it:"]
282
290
  + out
283
- + ([f" the text stays recoverable at {head}"] if head else [])
284
- + [" `git show <commit>:<path>` — and `jarvis work align` reports "
285
- "what cites a file that is gone"])
291
+ + [" `jarvis work align` reports what cites a file that is gone"])
292
+
286
293
 
294
+ #: What a released plan opens with. Matched on the marker rather than the whole line,
295
+ #: so a cut reopened and released again finds its own stamp instead of stacking a second.
296
+ _RELEASED_MARKER = "> **A released plan"
287
297
 
288
- def _still_holds(root) -> str:
289
- """The commit whose tree still contains these plans, or empty when git cannot say.
290
298
 
291
- HEAD is the right answer, and it is worth being precise about why: the unlink has
292
- not happened yet and the board write commits after it, so at the moment this
293
- prints, the newest commit in the history is one that still holds every file about
294
- to go. Through the same seam every other git question goes through a second way
295
- of asking would eventually give a second answer.
299
+ def _stamp_released(epic, version_name) -> bool:
300
+ """Mark a plan as history in the file itself. True when it was added.
301
+
302
+ In the document rather than in the folder name, because a reader who followed a
303
+ link is holding the document and never saw the folder. After the frontmatter and
304
+ before the first heading, which is the one place it cannot be scrolled past.
296
305
  """
297
- from . import git
298
- code, out, _ = git._git(root.parent, "rev-parse", "--short", "HEAD")
299
- return out.strip() if code == 0 else ""
306
+ text = epic.md.read_text()
307
+ if _RELEASED_MARKER in text:
308
+ return False
309
+ banner = (f"{_RELEASED_MARKER} — `{version_name}` shipped "
310
+ f"{date.today().isoformat()}.**\n"
311
+ f"> It says how the work was planned, never what is true now. Anything here\n"
312
+ f"> that still governs belongs in the file that owns it — `jarvis work where "
313
+ f"<id>` finds one.\n\n")
314
+ if text.startswith("---\n"):
315
+ close = text.index("\n---\n", 3) + len("\n---\n")
316
+ text = text[:close] + "\n" + banner + text[close:].lstrip("\n")
317
+ else:
318
+ text = banner + text
319
+ epic.md.write_text(text)
320
+ return True
300
321
 
301
322
 
302
323
  def cmd_epic_release(root, version) -> int:
303
- """Remove every `epic.md` in a released version, and report it.
324
+ """Stamp every `epic.md` in a released version as history, and KEEP it.
304
325
 
305
- An epic is TEMPORARY by definition: it is the plan-it-together doc, and how
306
- the work was planned stops being true the moment it ships. The folder stays
307
- as the grouping of what shipped that record is worth keeping — and git
308
- holds the plan. Called from `cmd_release`, never on its own.
326
+ An epic is temporary: how the work was planned stops being true the moment it
327
+ ships. What does not follow is that the file should go. Deleting it was the rule
328
+ until 2026-09-12, and the price was measured rather than argued: releasing
329
+ `01-one-board` removed twelve plans, left 104 links across the repo pointing at a
330
+ file that no longer existed, and put the text somewhere only a person who knew the
331
+ commit could reach. Nobody reads git for a decision.
309
332
 
310
- It says what it is deleting FIRST. The cost of a release is not the stamp, it is
311
- the plans that go with it, and a person deciding needs that in front of them
312
- while the files still exist."""
313
- for line in _cost_of_removing(version, root):
333
+ So the plan stays where every link already points and travels to the archive with
334
+ the rest of the cut, which `links.repairing` handles like any other move. The
335
+ sweep that lifts durable statements into their owner files is still worth doing —
336
+ it is just no longer a race against an unlink.
337
+
338
+ Called from `cmd_release`, never on its own.
339
+ """
340
+ for line in _cost_of_closing(version, root):
314
341
  print(line)
315
- removed = []
342
+ stamped = []
316
343
  for e in version.epics:
317
- # An epic with no plan doc is already in the shape this produces a folder
318
- # grouping tasks. Unlinking regardless raised AFTER `released:` had been
319
- # stamped, so the cut read as released while every other epic kept the file
320
- # this exists to remove.
344
+ # An epic with no plan doc has nothing to stamp. It was also the case that
345
+ # unlinking regardless raised AFTER `released:` was stamped, leaving the cut
346
+ # reading as released with every other plan untouched.
321
347
  if not e.planned:
322
348
  continue
323
- e.md.unlink()
324
- removed.append(e.name)
325
- if removed:
326
- print(f" archived {len(removed)} epic(s) epic.md removed, "
327
- f"folders kept as the grouping: {', '.join(removed)}")
328
- return len(removed)
349
+ if _stamp_released(e, version.name):
350
+ stamped.append(e.name)
351
+ if stamped:
352
+ print(f" stamped {len(stamped)} epic plan(s) as released and kept them "
353
+ f"in place: {', '.join(stamped)}")
354
+ return len(stamped)
355
+
356
+
329
357
  def epic_for_task(root, args, version_name):
330
358
  """Resolve the `--epic` a task command names, and check it agrees with any
331
359
  `--version` also given. Returns (epic, version_name) — the version is
@@ -27,7 +27,7 @@ from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
27
27
  from .frontmatter import rewrite_file
28
28
  from .model import locate, missing, record_session, scan
29
29
  from .generate import _sync
30
- from .epic import removal_cost
30
+ from .epic import plans_held
31
31
  from . import autonomy, events, links, peers
32
32
  # The ceiling is read through the MODULE, never bound in with `from … import`.
33
33
  # A `from .autonomy import CEILING` captures the value at import time, so
@@ -286,10 +286,11 @@ def cmd_status(args) -> int:
286
286
  ready = [v for v in s["versions"] if v.finishable()]
287
287
  print(f"WAITING ON YOU ({len(waiting) + len(ready)})")
288
288
  for v in ready:
289
- plans, size, held = removal_cost(v)
289
+ plans, size, held = plans_held(v)
290
290
  cost = ""
291
291
  if plans:
292
- cost = (f" — releasing deletes {plans} epic plan(s), {size:,} bytes"
292
+ cost = (f" — releasing stamps {plans} epic plan(s) as history, "
293
+ f"{size:,} bytes"
293
294
  + (f", holding §{', §'.join(held)}" if held else ""))
294
295
  print(f" {v.name}: every task is complete and the cut is still open "
295
296
  f"— `jarvis work release {v.name}`{cost}")
@@ -198,12 +198,12 @@ def cmd_release(args) -> int:
198
198
  # how the work was planned stops being true once it ships.
199
199
  cmd_epic_release(root, version)
200
200
  print("\nDistill before archiving:")
201
- # Points at the commit the deletion just named, because this instruction used to
202
- # send a reader to files the same command had already removed — and the one time
203
- # it mattered, the recovery commit had to be reconstructed afterwards with
204
- # `git log --diff-filter=D`.
201
+ # The plans are still in the tree, stamped as history, so this points at them
202
+ # rather than at a commit. It sent a reader to files the same command had already
203
+ # removed until 2026-09-12, and the one time it mattered the recovery commit had
204
+ # to be reconstructed afterwards with `git log --diff-filter=D`.
205
205
  print(" 1. Promote still-load-bearing decisions to the domain or system that owns "
206
- "them — read them from the commit named above; they are no longer in the tree")
206
+ "them — the plans are stamped and kept, so read them where they sit")
207
207
  print(f" 2. Repoint any inbound deep-links to those {ids.LEDGER}-nn entries")
208
208
  print(f" 3. {cli()} archive {name} (strips each task to task.md, and "
209
209
  f"files a RELEASED cut under versions/complete/)")
@@ -308,9 +308,9 @@ files are deleted. **Read a dissolving `epic.md` in full first** — a thin epic
308
308
  fully-planned, deliberately-deferred goal hides.
309
309
 
310
310
  A version's status is derived: **planned** until tasks start, **current** while any is in-progress,
311
- **released** once `jarvis work release` stamps it. Release **flattens the done tier**, removes each
312
- `epic.md`, then `jarvis work archive` strips each task to its `task.md` and moves the version to
313
- `work/archive/versions/<v>/`.
311
+ **released** once `jarvis work release` stamps it. Release **flattens the done tier** and **stamps
312
+ each `epic.md` as history, keeping it**, then `jarvis work archive` strips each task to its `task.md`
313
+ and moves the version to `work/archive/versions/<v>/`.
314
314
 
315
315
  **Archived is off the board, not gone.** `list`, the README table and every lint see only `versions/`.
316
316
  `jarvis work path <v>` and `jarvis work where <id>` still resolve into the archive.
@@ -338,8 +338,9 @@ is a brief and nothing else.
338
338
  says so, and it is right: that is an epic wearing a task costume. Split it by *goal*, not by layer.
339
339
 
340
340
  `epic.md` is mandatory for an epic and owns three things nothing else does — the **Goal** in user
341
- terms, the **Plan** every task builds against, and **Governance this implies**. It is **removed by
342
- `jarvis work release`**.
341
+ terms, the **Plan** every task builds against, and **Governance this implies**. `jarvis work
342
+ release` **stamps it as history and keeps it** — it used to delete it, which cost 104 dangling links
343
+ and put the text where only somebody who knew the commit could read it (2026-09-12).
343
344
 
344
345
  `handoff.md` is the **cross-conversation** artifact, not a per-task journal: **not** scaffolded on
345
346
  pickup — create it with `jarvis work handoff <name>` only when handing the task to a fresh session. A task
@@ -459,8 +460,8 @@ The two task-count rules are one rule from both ends: **a task is one goal, an e
459
460
  **done** epic is exempt from the floor; it is history, not a plan.
460
461
 
461
462
  `jarvis work align` reports every misalignment class — dangling citation · orphan/uncovered AC · unenforced
462
- rule · retired concept still named live · complete-but-unreleased version · an `epic.md` surviving a
463
- release. Report-only, exit 0.
463
+ rule · retired concept still named live · complete-but-unreleased version · a released cut whose
464
+ `epic.md` is not stamped as history. Report-only, exit 0.
464
465
 
465
466
  ## Every board write lands in git
466
467
 
@@ -716,8 +717,10 @@ The spend is authored prose. Keep it tight:
716
717
  - **Cannot `new` or `epic-new` into a released version.**
717
718
  - **Cannot `release`** a version with incomplete tasks, zero tasks, or no `outcome:`. **Cannot
718
719
  `archive`** one that isn't released.
719
- - **`release` deletes every `epic.md`.** Anything durable in an epic's §Plan or §Governance must have
720
- landed in its owner doc before you releasegit keeps the text, but nobody will look.
720
+ - **`release` stamps every `epic.md` as history and keeps it** (2026-09-12; it deleted them until
721
+ then). Nothing is lost and every link still resolves but a stamped plan is history, so anything
722
+ durable in its §Plan or §Governance still belongs in the file that owns it. A reader who needs a
723
+ decision reads the owner file, and will not go looking in a shipped cut's plan.
721
724
  - **Names are globally unique across tasks, epics and versions.** Renaming = move the folder by hand +
722
725
  update any `depends_on:`.
723
726
  - **`README.md` BACKLOG markers are load-bearing** — preserve them if you hand-edit around the table.
@@ -506,21 +506,43 @@ def test_a_grandfathered_flat_version_still_reads():
506
506
  assert [t.epic for t in ver.all_tasks()] == [None]
507
507
 
508
508
 
509
- def test_release_removes_epic_md_without_orphaning_its_tasks():
510
- # The bug this guards: `release` deletes the plan doc, so "an epic is a dir
511
- # with epic.md" would stop matching and the shipped tasks would disappear.
509
+ def test_release_stamps_the_plan_as_history_and_keeps_it():
510
+ # Release deleted the plan until 2026-09-12. It cost 104 dangling links across
511
+ # the repo and put the text where only somebody who knew the commit could read
512
+ # it, so the plan is now stamped and kept. What this guards is that the stamp
513
+ # actually lands in the file a reader opens — a folder-level marker would be
514
+ # invisible to anyone who arrived by following a link.
512
515
  with tempfile.TemporaryDirectory() as tmp:
513
516
  v = _tree(tmp)
514
517
  e = _epic(v, "publish-under-your-name")
515
518
  _task(e / "complete", "author-a-lesson")
516
519
  version.cmd_epic_release(Path(tmp), model.Version(v))
517
- assert not (e / "epic.md").exists()
520
+ assert (e / "epic.md").is_file()
521
+ body = (e / "epic.md").read_text()
522
+ assert epic._RELEASED_MARKER in body
523
+ # Before the first heading, so it cannot be scrolled past.
524
+ assert body.index(epic._RELEASED_MARKER) < body.index("# ")
518
525
  after = model.Version(v)
519
526
  assert [x.name for x in after.epics] == ["publish-under-your-name"]
520
- assert after.epics[0].planned is False
527
+ assert after.epics[0].planned is True
521
528
  assert [t.name for t in after.all_tasks()] == ["author-a-lesson"]
522
529
 
523
530
 
531
+ def test_releasing_twice_does_not_stack_a_second_stamp():
532
+ # A cut can be reopened and released again, and `align` reads the marker to tell
533
+ # a stamped plan from a live one. Two banners would still read as stamped, so
534
+ # this is about the document staying readable rather than about the check.
535
+ with tempfile.TemporaryDirectory() as tmp:
536
+ v = _tree(tmp)
537
+ e = _epic(v, "publish-under-your-name")
538
+ _task(e / "complete", "author-a-lesson")
539
+ version.cmd_epic_release(Path(tmp), model.Version(v))
540
+ first = (e / "epic.md").read_text()
541
+ version.cmd_epic_release(Path(tmp), model.Version(v))
542
+ assert (e / "epic.md").read_text() == first
543
+ assert first.count(epic._RELEASED_MARKER) == 1
544
+
545
+
524
546
  def test_a_finished_epic_drops_into_the_done_tier_and_comes_back_out():
525
547
  # The tier is DERIVED, so the only thing that may move an epic is what its
526
548
  # tasks say — and it has to work in BOTH directions, or reopening a task would
@@ -951,13 +973,13 @@ def test_a_flat_version_is_grandfathered_but_a_mixed_one_is_not():
951
973
  assert any("straggler: loose in version" in w for w in _warns(Path(tmp)))
952
974
 
953
975
 
954
- def test_release_removes_epic_md_for_an_epic_parked_in_the_done_tier():
976
+ def test_release_stamps_the_plan_of_an_epic_parked_in_the_done_tier():
955
977
  # THE bug on release's only normal path. A version is releasable only when every
956
978
  # task is complete — which is exactly the condition that parks each epic in
957
979
  # `<v>/complete/`. `release` flattens that tier first, then removed `epic.md`
958
980
  # from the PRE-MOVE model: FileNotFoundError, raised *after* `released:` was
959
- # already stamped. A traceback, a half-applied release, and `epic.md` the one
960
- # file release exists to remove — left behind for `align` to report.
981
+ # already stamped. A traceback, a half-applied release, and an unstamped plan
982
+ # left behind for `align` to report.
961
983
  import os
962
984
  with tempfile.TemporaryDirectory() as tmp:
963
985
  root = Path(tmp)
@@ -970,10 +992,13 @@ def test_release_removes_epic_md_for_an_epic_parked_in_the_done_tier():
970
992
  finally:
971
993
  os.environ.pop("WORK_DIR")
972
994
 
973
- # Flattened out of the done tier, epic.md gone, folder kept as the grouping.
995
+ # Flattened out of the done tier, the plan kept and stamped, folder kept as
996
+ # the grouping.
974
997
  assert (v / "a-shipped-goal").is_dir()
975
998
  assert not (v / tree.DONE_TIER).exists()
976
- assert not list(v.rglob("epic.md")), "release must remove every epic.md"
999
+ plans = list(v.rglob("epic.md"))
1000
+ assert len(plans) == 1, "release must keep the plan, not remove it"
1001
+ assert epic._RELEASED_MARKER in plans[0].read_text()
977
1002
  assert (v / "a-shipped-goal" / "complete" / "shipped-thing" / "task.md").is_file()
978
1003
  assert model.Version(v).released
979
1004
 
@@ -6394,7 +6419,8 @@ def test_an_epic_whose_plan_doc_is_gone_still_moves_into_a_cut():
6394
6419
  def test_releasing_a_cut_holding_an_epic_with_no_plan_doc_still_releases_it():
6395
6420
  # The unlink ran on every epic, so one without a plan doc raised — and it raised
6396
6421
  # AFTER `released:` was stamped, leaving the cut reading as released while every
6397
- # other epic kept the file release exists to remove.
6422
+ # other epic went untouched. The stamp has the same shape of hazard: an epic with
6423
+ # nothing to stamp must be skipped rather than read.
6398
6424
  with tempfile.TemporaryDirectory() as tmp:
6399
6425
  root = Path(tmp)
6400
6426
  v = _tree(tmp)
@@ -6412,9 +6438,10 @@ def test_releasing_a_cut_holding_an_epic_with_no_plan_doc_still_releases_it():
6412
6438
 
6413
6439
  after = model.locate_version(root, "26-cut")
6414
6440
  assert after.released, "the cut never got its released stamp"
6415
- assert not (after.folder / "publish-under-your-name" / "epic.md").exists() \
6416
- and not (after.folder / "complete" / "publish-under-your-name" / "epic.md").exists(), \
6417
- "a plan doc survived the release"
6441
+ plans = list(after.folder.rglob("epic.md"))
6442
+ assert len(plans) == 1, "the epic that had a plan should still have exactly one"
6443
+ assert plans[0].parent.name == "publish-under-your-name"
6444
+ assert epic._RELEASED_MARKER in plans[0].read_text(), "the kept plan is unstamped"
6418
6445
 
6419
6446
 
6420
6447
  def test_a_shipped_criterion_delivered_by_a_filed_cut_is_still_covered():
@@ -7403,13 +7430,13 @@ def test_the_stop_hook_being_switched_off_silences_the_standing_too():
7403
7430
 
7404
7431
 
7405
7432
 
7406
- def test_a_release_says_what_it_is_deleting_before_it_deletes_it():
7407
- # `release` has always printed "promote still-load-bearing decisions to the
7408
- # domain that owns them" AFTER unlinking the files holding those decisions.
7409
- # Measured on 01-one-board: twelve plans, 450,405 bytes, about a hundred durable
7410
- # statements with no owner file, and the recovery commit reconstructed afterwards
7411
- # by hand with `git log --diff-filter=D`. The cost of a release is the plans that
7412
- # go with it, so it is named while they still exist.
7433
+ def test_a_release_says_what_it_is_stamping_shut():
7434
+ # `release` has always printed "promote still-load-bearing decisions to the domain
7435
+ # that owns them", and until 2026-09-12 it printed that AFTER unlinking the files
7436
+ # holding those decisions. Measured on 01-one-board: twelve plans, 450,405 bytes,
7437
+ # about a hundred durable statements with no owner file. The plans are now kept
7438
+ # and stamped rather than deleted, so what the notice owes a reader changed with
7439
+ # it: not a last call before the bytes go, but the argument for the sweep.
7413
7440
  with tempfile.TemporaryDirectory() as tmp:
7414
7441
  try:
7415
7442
  repo = _git_repo(tmp, push=False)
@@ -7432,22 +7459,22 @@ def test_a_release_says_what_it_is_deleting_before_it_deletes_it():
7432
7459
  with _work_dir(str(repo / "work")):
7433
7460
  said = _capture_stdout(lambda: version.cmd_release({"name": "09-a-cut"}))
7434
7461
 
7435
- # The cost, in front of the person, in units they can weigh.
7436
- assert "DELETING 1 epic plan(s)" in said, said
7462
+ # What is going quiet, in front of the person, in units they can weigh.
7463
+ assert "STAMPING 1 epic plan(s)" in said, said
7437
7464
  assert "bytes" in said
7438
7465
  # WHICH sections can outlive the plan — and not the ones that cannot.
7439
7466
  assert "§Governance this implies" in said, said
7440
7467
  assert "§Plan" not in said, "slice ordering is supposed to die with the epic"
7441
- # Where it went, named at the moment it goes rather than reconstructed later.
7442
- assert head and head in said, f"expected the recovery commit {head}: {said}"
7443
7468
 
7444
- # And the claim is true: the plan is out of the tree and in that commit.
7445
- assert not (v / "an-epic" / "epic.md").exists()
7446
- kept = _git(repo, "show", f"{head}:work/versions/09-a-cut/an-epic/epic.md")
7447
- assert "a rule is owed" in kept.stdout, kept.stderr
7469
+ # And the claim is true: the plan is still there, and stamped.
7470
+ plan = v / "an-epic" / "epic.md"
7471
+ assert plan.is_file(), "a released plan is kept, not deleted"
7472
+ assert "a rule is owed" in plan.read_text()
7473
+ assert epic._RELEASED_MARKER in plan.read_text()
7448
7474
 
7449
- # The instruction that follows no longer sends a reader to a deleted file.
7450
- assert "no longer in the tree" in said, said
7475
+ # Nothing sends a reader to git for the text any more, because it is here.
7476
+ assert "no longer in the tree" not in said, said
7477
+ assert head not in said or "recoverable" not in said, said
7451
7478
  finally:
7452
7479
  events._PENDING.clear()
7453
7480
  config.apply(config.DEFAULTS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -57,16 +57,16 @@
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^2.1.0",
59
59
  "@jarvis/agents": "1.0.0",
60
- "@jarvis/errors": "1.0.0",
61
60
  "@jarvis/board": "0.1.0",
61
+ "@jarvis/data": "0.1.0",
62
+ "@jarvis/errors": "1.0.0",
62
63
  "@jarvis/logger": "1.0.0",
63
64
  "@jarvis/rpc": "1.0.0",
64
65
  "@jarvis/anthropic": "1.0.0",
65
- "@jarvis/typescript-config": "1.0.0",
66
- "@jarvis/data": "0.1.0",
67
66
  "@jarvis/types": "1.0.0",
68
- "@jarvis/ui": "0.1.0",
69
- "@jarvis/vitest-config": "1.0.0"
67
+ "@jarvis/typescript-config": "1.0.0",
68
+ "@jarvis/vitest-config": "1.0.0",
69
+ "@jarvis/ui": "0.1.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",