@appchy/jarvis 0.1.95 → 0.1.96

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
@@ -10154,7 +10154,7 @@ import { createRequire as createRequire2 } from "module";
10154
10154
  var _require = createRequire2(import.meta.url);
10155
10155
  var VERSION2 = _require("../package.json").version ?? "0.0.0";
10156
10156
  var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
10157
- var SHA = "20f7aad";
10157
+ var SHA = "db0cbed";
10158
10158
  var BUILT = "2026-09-11";
10159
10159
  var BUILD = SHA ?? "source";
10160
10160
  var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
@@ -212,13 +212,81 @@ def cmd_epic_move(args) -> int:
212
212
  f"with {len(moved)} task(s)")
213
213
  _sync(root)
214
214
  return 0
215
+ #: Headings whose content is a STATEMENT ABOUT THE WORLD rather than a plan for one
216
+ #: piece of work — the parts of an epic that can outlive it, and therefore the parts
217
+ #: worth naming before they are deleted. Slice ordering, as-found and running logs are
218
+ #: deliberately absent: those are supposed to die with the epic.
219
+ _DURABLE_SECTIONS = ("## Governance this implies", "## Non-goals", "### Settled",
220
+ "### Forward-compat")
221
+
222
+
223
+ def _cost_of_removing(version, root) -> list:
224
+ """What this release is about to delete, as lines a person can act on.
225
+
226
+ **Said BEFORE the unlink, because the advice that follows a release is useless
227
+ after it.** `release` has always printed _"promote still-load-bearing decisions
228
+ to the domain that owns them"_ — and printed it after the files holding those
229
+ decisions had already left the tree. Measured 2026-09-11 on `01-one-board`: twelve
230
+ plans, 450,405 bytes, about a hundred durable statements with no owner file, and
231
+ the recovery commit had to be worked out by hand afterwards from `git log
232
+ --diff-filter=D`.
233
+
234
+ So this names the size, the sections that can outlive the plan, and the commit
235
+ where the text still lives. It does not judge whether a statement is durable —
236
+ that is the judgement a lint cannot make, and claiming it would be worse than
237
+ saying nothing.
238
+ """
239
+ plans = [e for e in version.epics if e.planned]
240
+ if not plans:
241
+ return []
242
+ out, total = [], 0
243
+ for e in plans:
244
+ try:
245
+ text = e.md.read_text()
246
+ except OSError: # pragma: no cover — defensive
247
+ continue
248
+ total += len(text.encode())
249
+ held = [h.split("## ")[-1].split("### ")[-1]
250
+ for h in _DURABLE_SECTIONS if h in text]
251
+ note = f" — holds §{', §'.join(held)}" if held else ""
252
+ out.append(f" {e.name} {len(text.encode()):,} bytes{note}")
253
+ head = _still_holds(root)
254
+ return ([f"\n DELETING {len(plans)} epic plan(s), {total:,} bytes. Anything "
255
+ f"durable in them must already be in the file that owns it — after this "
256
+ f"they are in git only, and nobody reads git for a decision:"]
257
+ + out
258
+ + ([f" the text stays recoverable at {head}"] if head else [])
259
+ + [" `git show <commit>:<path>` — and `jarvis work align` reports "
260
+ "what cites a file that is gone"])
261
+
262
+
263
+ def _still_holds(root) -> str:
264
+ """The commit whose tree still contains these plans, or empty when git cannot say.
265
+
266
+ HEAD is the right answer, and it is worth being precise about why: the unlink has
267
+ not happened yet and the board write commits after it, so at the moment this
268
+ prints, the newest commit in the history is one that still holds every file about
269
+ to go. Through the same seam every other git question goes through — a second way
270
+ of asking would eventually give a second answer.
271
+ """
272
+ from . import git
273
+ code, out, _ = git._git(root.parent, "rev-parse", "--short", "HEAD")
274
+ return out.strip() if code == 0 else ""
275
+
276
+
215
277
  def cmd_epic_release(root, version) -> int:
216
278
  """Remove every `epic.md` in a released version, and report it.
217
279
 
218
280
  An epic is TEMPORARY by definition: it is the plan-it-together doc, and how
219
281
  the work was planned stops being true the moment it ships. The folder stays
220
282
  as the grouping of what shipped — that record is worth keeping — and git
221
- holds the plan. Called from `cmd_release`, never on its own."""
283
+ holds the plan. Called from `cmd_release`, never on its own.
284
+
285
+ It says what it is deleting FIRST. The cost of a release is not the stamp, it is
286
+ the plans that go with it, and a person deciding needs that in front of them
287
+ while the files still exist."""
288
+ for line in _cost_of_removing(version, root):
289
+ print(line)
222
290
  removed = []
223
291
  for e in version.epics:
224
292
  # An epic with no plan doc is already in the shape this produces — a folder
@@ -198,7 +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
- print(" 1. Promote still-load-bearing decisions to the domain or system that owns them")
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`.
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")
202
207
  print(f" 2. Repoint any inbound deep-links to those {ids.LEDGER}-nn entries")
203
208
  print(f" 3. {cli()} archive {name} (strips each task to task.md, and "
204
209
  f"files a RELEASED cut under versions/complete/)")
@@ -7288,6 +7288,81 @@ def test_the_stop_hook_being_switched_off_silences_the_standing_too():
7288
7288
 
7289
7289
 
7290
7290
 
7291
+ def test_a_release_says_what_it_is_deleting_before_it_deletes_it():
7292
+ # `release` has always printed "promote still-load-bearing decisions to the
7293
+ # domain that owns them" — AFTER unlinking the files holding those decisions.
7294
+ # Measured on 01-one-board: twelve plans, 450,405 bytes, about a hundred durable
7295
+ # statements with no owner file, and the recovery commit reconstructed afterwards
7296
+ # by hand with `git log --diff-filter=D`. The cost of a release is the plans that
7297
+ # go with it, so it is named while they still exist.
7298
+ with tempfile.TemporaryDirectory() as tmp:
7299
+ try:
7300
+ repo = _git_repo(tmp, push=False)
7301
+ v = repo / "work" / "versions" / "09-a-cut"
7302
+ (v / "an-epic" / "complete" / "only-task").mkdir(parents=True)
7303
+ (v / "version.md").write_text(
7304
+ "---\ncreated: 2026-09-01\norder: 9\noutcome: a user can do it\n"
7305
+ "---\n\n# A cut\n")
7306
+ (v / "an-epic" / "epic.md").write_text(
7307
+ "---\ntype: epic\nowner: quality\n---\n\n# An epic\n\n## Plan\n\n"
7308
+ "Slice ordering, which is supposed to die with this file.\n\n"
7309
+ "## Governance this implies\n\n- quality/README.md — a rule is owed\n")
7310
+ (v / "an-epic" / "complete" / "only-task" / "task.md").write_text(
7311
+ "---\npriority: P0\ncompleted: 2026-09-02\n---\n\n# Only task\n\n"
7312
+ "## Acceptance criteria\n\n- [x] it works\n")
7313
+ _git(repo, "add", "-A")
7314
+ _git(repo, "commit", "-qm", "the board")
7315
+ head = _git(repo, "rev-parse", "--short", "HEAD").stdout.strip()
7316
+
7317
+ with _work_dir(str(repo / "work")):
7318
+ said = _capture_stdout(lambda: version.cmd_release({"name": "09-a-cut"}))
7319
+
7320
+ # The cost, in front of the person, in units they can weigh.
7321
+ assert "DELETING 1 epic plan(s)" in said, said
7322
+ assert "bytes" in said
7323
+ # WHICH sections can outlive the plan — and not the ones that cannot.
7324
+ assert "§Governance this implies" in said, said
7325
+ assert "§Plan" not in said, "slice ordering is supposed to die with the epic"
7326
+ # Where it went, named at the moment it goes rather than reconstructed later.
7327
+ assert head and head in said, f"expected the recovery commit {head}: {said}"
7328
+
7329
+ # And the claim is true: the plan is out of the tree and in that commit.
7330
+ assert not (v / "an-epic" / "epic.md").exists()
7331
+ kept = _git(repo, "show", f"{head}:work/versions/09-a-cut/an-epic/epic.md")
7332
+ assert "a rule is owed" in kept.stdout, kept.stderr
7333
+
7334
+ # The instruction that follows no longer sends a reader to a deleted file.
7335
+ assert "no longer in the tree" in said, said
7336
+ finally:
7337
+ events._PENDING.clear()
7338
+ config.apply(config.DEFAULTS)
7339
+
7340
+
7341
+ def test_a_release_with_no_plans_left_says_nothing_about_deleting_any():
7342
+ # Silence where there is no cost. An epic with no plan doc is already in the
7343
+ # shape release produces, and announcing a deletion of nothing would train the
7344
+ # reader to skip the announcement that matters.
7345
+ with tempfile.TemporaryDirectory() as tmp:
7346
+ try:
7347
+ repo = _git_repo(tmp, push=False)
7348
+ v = repo / "work" / "versions" / "09-a-cut"
7349
+ (v / "an-epic" / "complete" / "only-task").mkdir(parents=True)
7350
+ (v / "version.md").write_text(
7351
+ "---\ncreated: 2026-09-01\norder: 9\noutcome: a user can do it\n"
7352
+ "---\n\n# A cut\n")
7353
+ (v / "an-epic" / "complete" / "only-task" / "task.md").write_text(
7354
+ "---\npriority: P0\ncompleted: 2026-09-02\n---\n\n# Only task\n\n"
7355
+ "## Acceptance criteria\n\n- [x] it works\n")
7356
+ _git(repo, "add", "-A")
7357
+ _git(repo, "commit", "-qm", "the board")
7358
+ with _work_dir(str(repo / "work")):
7359
+ said = _capture_stdout(lambda: version.cmd_release({"name": "09-a-cut"}))
7360
+ assert "DELETING" not in said, said
7361
+ finally:
7362
+ events._PENDING.clear()
7363
+ config.apply(config.DEFAULTS)
7364
+
7365
+
7291
7366
  if __name__ == "__main__":
7292
7367
  tests = [v for k, v in sorted(globals().items())
7293
7368
  if k.startswith("test_") and callable(v)]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.95",
3
+ "version": "0.1.96",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -58,13 +58,13 @@
58
58
  "vitest": "^2.1.0",
59
59
  "@jarvis/agents": "1.0.0",
60
60
  "@jarvis/anthropic": "1.0.0",
61
- "@jarvis/board": "0.1.0",
62
- "@jarvis/data": "0.1.0",
63
61
  "@jarvis/errors": "1.0.0",
62
+ "@jarvis/board": "0.1.0",
64
63
  "@jarvis/logger": "1.0.0",
65
64
  "@jarvis/rpc": "1.0.0",
66
65
  "@jarvis/types": "1.0.0",
67
66
  "@jarvis/typescript-config": "1.0.0",
67
+ "@jarvis/data": "0.1.0",
68
68
  "@jarvis/ui": "0.1.0",
69
69
  "@jarvis/vitest-config": "1.0.0"
70
70
  },