@appchy/jarvis 0.1.82 → 0.1.84

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.
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
 
3
3
  from . import ids
4
4
  from .tree import BACKLOG_END, BACKLOG_START, assets_dir, die, find_work_root, rel
5
- from .model import locate
5
+ from .model import locate, missing
6
6
  from .registry import (DOMAIN_ORDER, _citation_counts, definition_sites,
7
7
  domains_for_owner, hosts, locate_domain, locate_system,
8
8
  scan_systems, systems_for_code)
@@ -248,7 +248,7 @@ def cmd_rules(args) -> int:
248
248
  die("usage: jarvis work rules --task <name>")
249
249
  t = locate(root, name)
250
250
  if not t:
251
- die(f"no task named '{name}'")
251
+ die(missing(root, name))
252
252
  systems = systems_for_code(root, t.code) if t.code else []
253
253
  domains = domains_for_owner(root, t.owner)
254
254
  if not systems and not domains:
@@ -28,7 +28,7 @@ from datetime import date
28
28
  from .tree import (BLOCKED, BUCKETS, DEFAULT_AUTONOMY_CEILING, TIER3_OWNERS,
29
29
  TIER_MEANING, TIERS, die, find_work_root, rel)
30
30
  from .frontmatter import as_list, parse_frontmatter, rewrite_file
31
- from .model import locate, record_session, scan
31
+ from .model import locate, missing, record_session, scan
32
32
  from .generate import _sync
33
33
  from . import events, links
34
34
 
@@ -129,7 +129,7 @@ def cmd_ask(args) -> int:
129
129
  "[--owner founder] [--durable]")
130
130
  task = locate(root, name)
131
131
  if not task:
132
- die(f"no task named '{name}' found")
132
+ die(missing(root, name))
133
133
  if task.in_backlog:
134
134
  die(f"'{name}' is in backlog — a backlog task has no status to park. "
135
135
  f"Pull it into a version first.")
@@ -195,7 +195,7 @@ def cmd_answer(args) -> int:
195
195
  die("usage: jarvis work answer <task> --choose \"…\"")
196
196
  task = locate(root, name)
197
197
  if not task:
198
- die(f"no task named '{name}' found")
198
+ die(missing(root, name))
199
199
  open_qs = _open_questions(task)
200
200
  if not open_qs:
201
201
  die(f"'{name}' has no open question — nothing to answer")
@@ -33,7 +33,7 @@ from pathlib import Path
33
33
 
34
34
  from .tree import die, find_work_root
35
35
  from .frontmatter import as_list, parse_frontmatter, rewrite_file, split_frontmatter
36
- from .model import locate
36
+ from .model import locate, missing
37
37
  from .shard import _load_run
38
38
  from . import events, git, peers
39
39
  from .autonomy import tier_of
@@ -277,7 +277,7 @@ def _execute(root, name) -> tuple:
277
277
  _write_run(root, data)
278
278
 
279
279
  if name and not _record(root, name, results, ok, sha):
280
- die(f"no task named '{name}' found")
280
+ die(missing(root, name))
281
281
  return results, ok, sha
282
282
 
283
283
 
@@ -530,7 +530,7 @@ def cmd_observed(args) -> int:
530
530
  die(f"--ac must look like 'AC-01' (got '{ac}')")
531
531
  task = locate(root, name)
532
532
  if not task:
533
- die(f"no task named '{name}' found")
533
+ die(missing(root, name))
534
534
  entry = f"{date.today().isoformat()} {ac} {saw}"
535
535
  md = task.folder / "task.md"
536
536
 
@@ -23,7 +23,7 @@ assuming a fresh session infers it.
23
23
  """
24
24
  from pathlib import Path
25
25
 
26
- from .model import locate
26
+ from .model import locate, missing
27
27
  from .tree import cli, die, find_work_root, rel
28
28
 
29
29
  #: `graph.mcp`, `session.mcp`, `session.tool`, `spine.*`, `instructions.skill` —
@@ -182,7 +182,7 @@ def cmd_kickoff(args) -> int:
182
182
  root = find_work_root()
183
183
  task = locate(root, args["name"])
184
184
  if not task:
185
- die(f"no task named '{args['name']}' found")
185
+ die(missing(root, args["name"]))
186
186
  prompt = build_prompt(task, root, args.get("next"), args.get("watch"))
187
187
 
188
188
  # `--prompt-only` is the pipe. Everything else this command prints is
@@ -3,7 +3,7 @@ import re
3
3
  from datetime import date
4
4
  from pathlib import Path
5
5
 
6
- from .tree import backlog_dir, BUCKETS, DONE_TIER, PRIORITIES, RESERVED_MD
6
+ from .tree import backlog_dir, BUCKETS, cli, DONE_TIER, PRIORITIES, RESERVED_MD
7
7
  from .frontmatter import as_list, parse_frontmatter, read_item, rewrite_file, title_of
8
8
 
9
9
 
@@ -298,8 +298,22 @@ def scan(root: Path) -> dict:
298
298
 
299
299
  return {"versions": versions, "backlog": backlog,
300
300
  "backlog_epics": backlog_epics}
301
- def locate(root: Path, name: str):
302
- """Find a task by name anywhere. Returns a Task or None."""
301
+ def locate(root: Path, name: str, filed: bool = False):
302
+ """Find a task by name. LIVE work by default; `filed=True` also searches the
303
+ cuts that have left the board.
304
+
305
+ `locate_version` has always searched all three homes, and says why: off the
306
+ board is not gone, and `path` keeps answering. That was never true of the work
307
+ INSIDE a cut — filing `00-somebody-new-can-start` made six tasks unresolvable
308
+ in one command, and between them they are named in 55 commit trailers, which
309
+ is the record a board write leaves.
310
+
311
+ **It is a parameter rather than the new default, and that is the whole care
312
+ here.** `locate` is also how every WRITE resolves its target, and what a
313
+ release delivered is a matter of record — a task inside a shipped cut must not
314
+ become movable. So reads opt in, and writes stay on live work and refuse by
315
+ saying where the work went rather than claiming it never existed.
316
+ """
303
317
  s = scan(root)
304
318
  for v in s["versions"]:
305
319
  for t in v.all_tasks():
@@ -308,7 +322,63 @@ def locate(root: Path, name: str):
308
322
  for t in s["backlog"]:
309
323
  if t.name == name:
310
324
  return t
325
+ if filed:
326
+ for v in scan_filed(root):
327
+ for t in v.all_tasks():
328
+ if t.name == name:
329
+ return t
311
330
  return None
331
+
332
+
333
+ def scan_filed(root: Path) -> list:
334
+ """Every cut that has LEFT the board — both `versions/complete/` and
335
+ `versions/archive/`.
336
+
337
+ Deliberately not `scan_shipped`, which reads `complete/` alone because it
338
+ answers "what has this repo delivered". A cut in `archive/` delivered nothing:
339
+ it left because its work moved elsewhere and there was no outcome it could
340
+ honestly claim. Two different questions, so two functions rather than one with
341
+ a flag that makes every caller state which meaning it wanted.
342
+ """
343
+ out = []
344
+ for home in ("complete", "archive"):
345
+ base = root / "versions" / home
346
+ if not base.is_dir():
347
+ continue
348
+ out += [Version(v) for v in sorted(base.iterdir())
349
+ if v.is_dir() and (v / "version.md").is_file()]
350
+ return out
351
+
352
+
353
+ def filed_home(root: Path, name: str) -> str:
354
+ """Where a name went, as a sentence to append to a refusal — or `""`.
355
+
356
+ "no task named 'x' found" is the least useful true thing to say about work that
357
+ shipped last Thursday, and it is what every write said the moment a cut was
358
+ filed. The lookup that fails looks once more, in the cuts that left the board,
359
+ so the refusal can name the release instead of denying the work ever existed.
360
+ """
361
+ for v in scan_filed(root):
362
+ for t in v.all_tasks():
363
+ if t.name == name:
364
+ return (f" — it shipped in '{v.name}'"
365
+ + (f" on {v.released}" if v.released else "")
366
+ + ", and what a release delivered is a matter of record. "
367
+ + f"Read it with `{cli()} path {name}`.")
368
+ for e in v.epics:
369
+ if e.name == name:
370
+ return (f" — that epic shipped in '{v.name}'"
371
+ + (f" on {v.released}" if v.released else "") + ".")
372
+ return ""
373
+
374
+
375
+ def missing(root: Path, name: str, noun: str = "task") -> str:
376
+ """The refusal for a name that does not resolve, saying where it went if it did.
377
+
378
+ One wording in one place: thirteen sites said "no task named 'x' found" and
379
+ every one of them was wrong in the same new way once cuts started being filed.
380
+ """
381
+ return f"no {noun} named '{name}' found{filed_home(root, name)}"
312
382
  def locate_version(root: Path, name: str):
313
383
  """Find a version by name. Looks in BOTH homes a cut leaves the board for —
314
384
  `versions/complete/` and `versions/archive/` — so a finished cut stays
@@ -347,9 +417,13 @@ def scan_shipped(root: Path) -> list:
347
417
  cuts.sort(key=lambda v: (v.order, v.name))
348
418
  cuts.sort(key=lambda v: v.released or "", reverse=True)
349
419
  return cuts
350
- def locate_epic(root: Path, name: str):
420
+ def locate_epic(root: Path, name: str, filed: bool = False):
351
421
  """Find an epic by name anywhere — in a version or in the backlog. Names are
352
- globally unique across tasks, epics and versions, so a name alone resolves."""
422
+ globally unique across tasks, epics and versions, so a name alone resolves.
423
+
424
+ `filed=True` reaches the cuts that have left the board, for the reason
425
+ `locate` takes the same parameter: a grouping that shipped is still a name
426
+ people cite, and `release` removes only the plan doc, never the folder."""
353
427
  s = scan(root)
354
428
  for v in s["versions"]:
355
429
  for e in v.epics:
@@ -358,6 +432,11 @@ def locate_epic(root: Path, name: str):
358
432
  for e in s["backlog_epics"]:
359
433
  if e.name == name:
360
434
  return e
435
+ if filed:
436
+ for v in scan_filed(root):
437
+ for e in v.epics:
438
+ if e.name == name:
439
+ return e
361
440
  return None
362
441
  def epic_home(root: Path, epic: "Epic") -> str:
363
442
  """A human-readable home for an epic — `version '<v>'` or `backlog`."""
@@ -25,7 +25,7 @@ from pathlib import Path
25
25
 
26
26
  from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
27
27
  from .frontmatter import rewrite_file
28
- from .model import locate, record_session, scan
28
+ from .model import locate, missing, record_session, scan
29
29
  from .generate import _sync
30
30
  from . import autonomy, events, links, peers
31
31
  # The ceiling is read through the MODULE, never bound in with `from … import`.
@@ -225,7 +225,7 @@ def cmd_drop(args) -> int:
225
225
  root = find_work_root()
226
226
  task = locate(root, args["name"])
227
227
  if not task:
228
- die(f"no task named '{args['name']}' found")
228
+ die(missing(root, args["name"]))
229
229
  p = task.folder / CLAIM
230
230
  if p.is_file():
231
231
  p.unlink()
@@ -10,7 +10,7 @@ from . import peers, tree
10
10
  from . import links
11
11
  from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
12
12
  from .frontmatter import as_list, parse_frontmatter, read_item, rewrite_file, split_frontmatter
13
- from .model import current_session_id, locate, locate_epic, locate_version, record_session, scan
13
+ from .model import current_session_id, locate, locate_epic, locate_version, missing, record_session, scan
14
14
  from .registry import code_vocabulary
15
15
  from .scaffold import _check_kebab, _check_owner_ref, _check_priority, _check_unused, _scaffold_handoff, _scaffold_task
16
16
  from .epic import _bucketed, cmd_epic_move, epic_for_task
@@ -160,7 +160,7 @@ def cmd_place(args) -> int:
160
160
 
161
161
  task = locate(root, name)
162
162
  if not task:
163
- die(f"no task or epic named '{name}' found")
163
+ die(missing(root, name, "task or epic"))
164
164
  if task.version == version.name:
165
165
  die(f"'{name}' is already in version '{version_name}'")
166
166
  if task.status == "complete":
@@ -236,7 +236,7 @@ def _to_backlog(root, name: str, args) -> int:
236
236
 
237
237
  task = locate(root, name)
238
238
  if not task:
239
- die(f"no task or epic named '{name}' found")
239
+ die(missing(root, name, "task or epic"))
240
240
  if task.in_backlog:
241
241
  die(f"'{name}' is already in the backlog")
242
242
  if task.status == "complete":
@@ -283,7 +283,7 @@ def cmd_move(args) -> int:
283
283
 
284
284
  task = locate(root, name)
285
285
  if not task:
286
- die(f"no task named '{name}' found")
286
+ die(missing(root, name))
287
287
  if task.in_backlog:
288
288
  die(f"'{name}' is in backlog — pull it into a version first "
289
289
  f"(jarvis work place {name} --version <v>)")
@@ -308,13 +308,13 @@ def cmd_move(args) -> int:
308
308
  delivered = (args.get("delivered") or "").strip()
309
309
  not_included = (args.get("not-included") or args.get("not_included") or "").strip()
310
310
  if to == "complete":
311
- missing = delivery_gate(delivered, not_included)
312
- if missing:
311
+ unmet = delivery_gate(delivered, not_included)
312
+ if unmet:
313
313
  print(f"error: '{name}' cannot complete — the delivery line is "
314
314
  f"incomplete:", file=sys.stderr)
315
- for m in missing:
315
+ for m in unmet:
316
316
  print(f" · {m}", file=sys.stderr)
317
- events.append(root, "gate-refused", name, why="; ".join(missing)[:300])
317
+ events.append(root, "gate-refused", name, why="; ".join(unmet)[:300])
318
318
  return 1
319
319
  if not report_gate(root, task, (args.get("accept") or "").strip(),
320
320
  (args.get("owner") or "").strip()):
@@ -376,7 +376,7 @@ def cmd_handoff(args) -> int:
376
376
  name = args["name"]
377
377
  task = locate(root, name)
378
378
  if not task:
379
- die(f"no task named '{name}' found")
379
+ die(missing(root, name))
380
380
  if task.in_backlog or task.status == "queue":
381
381
  die(f"'{name}' is queued/backlog — handoff is for in-progress work "
382
382
  f"(pick it up first: jarvis work move {name} in-progress)")
@@ -392,7 +392,7 @@ def cmd_session(args) -> int:
392
392
  root = find_work_root()
393
393
  task = locate(root, args["name"])
394
394
  if not task:
395
- die(f"no task named '{args['name']}' found")
395
+ die(missing(root, args["name"]))
396
396
  given = (args.get("id") or "").strip()
397
397
  label = None
398
398
  if given:
@@ -433,7 +433,7 @@ def cmd_plan(args) -> int:
433
433
  name = args["name"]
434
434
  task = locate(root, name)
435
435
  if not task:
436
- die(f"no task named '{name}' found")
436
+ die(missing(root, name))
437
437
  if not args.get("file") and not PLANS_DIR:
438
438
  die("no `plans.dir` configured — pass `--file <path>`, or set "
439
439
  "`plans.dir` in .claude/work.config.json if this repo keeps a "
@@ -457,13 +457,19 @@ def cmd_plan(args) -> int:
457
457
  print(f"recorded plan '{path}' on '{task.name}'")
458
458
  return 0
459
459
  def cmd_path(args) -> int:
460
+ """Resolve any name to its folder — including work in a cut that has shipped.
461
+
462
+ This is the READ door, so it opts into the filed cuts at every tier.
463
+ `locate_version` already did; the two below did not, which is how filing one
464
+ cut made six tasks unresolvable while the cut holding them still answered.
465
+ """
460
466
  root = find_work_root()
461
467
  name = args["name"]
462
- task = locate(root, name)
468
+ task = locate(root, name, filed=True)
463
469
  if task:
464
470
  print(task.folder)
465
471
  return 0
466
- epic = locate_epic(root, name)
472
+ epic = locate_epic(root, name, filed=True)
467
473
  if epic:
468
474
  print(epic.folder)
469
475
  return 0
@@ -2770,6 +2770,91 @@ def test_a_formatter_cannot_quietly_break_the_scaffolding_templates():
2770
2770
  f"reformatted them: {offenders[:5]}")
2771
2771
 
2772
2772
 
2773
+ def _filed_cut(tmp: str, home: str = "complete", released: str = "2026-09-11"):
2774
+ """A cut that has LEFT the board, with one epic and one completed task in it."""
2775
+ root = Path(tmp)
2776
+ v = root / "versions" / home / "00-shipped"
2777
+ task = v / "an-epic" / "complete" / "the-work"
2778
+ task.mkdir(parents=True)
2779
+ (root / "versions").mkdir(parents=True, exist_ok=True)
2780
+ (root / "versions" / "backlog").mkdir(exist_ok=True)
2781
+ (root / "README.md").write_text(f"# Work\n\n{tree.BACKLOG_START}\n{tree.BACKLOG_END}\n")
2782
+ rel = f"released: {released}\n" if released else ""
2783
+ (v / "version.md").write_text(
2784
+ f"---\ncreated: 2026-08-01\norder: 0\noutcome: x\n{rel}archived: 2026-09-11\n---\n\n# Shipped\n")
2785
+ (task / "task.md").write_text("---\npriority: P0\n---\n\n# The work\n")
2786
+ return v
2787
+
2788
+
2789
+ def test_work_inside_a_filed_cut_still_answers_to_its_name():
2790
+ """`locate_version` always searched all three homes — off the board is not gone,
2791
+ and `path` keeps answering. The work INSIDE a cut never did.
2792
+
2793
+ Filing one cut made six tasks unresolvable in a single command, and between
2794
+ them they were named in 55 commit trailers, which is the record a board write
2795
+ leaves. Both homes, because a cut leaves the board for two different reasons.
2796
+ """
2797
+ for home in ("complete", "archive"):
2798
+ with tempfile.TemporaryDirectory() as tmp:
2799
+ _filed_cut(tmp, home)
2800
+ with _work_dir(tmp) as root:
2801
+ # Live resolution still sees only live work — `list`, the README
2802
+ # and every lint depend on that and must not start seeing shipped
2803
+ # work because reads were widened.
2804
+ assert model.locate(root, "the-work") is None
2805
+ assert model.locate_epic(root, "an-epic") is None
2806
+ assert model.scan(root)["versions"] == []
2807
+
2808
+ found = model.locate(root, "the-work", filed=True)
2809
+ assert found and found.name == "the-work", home
2810
+ epic = model.locate_epic(root, "an-epic", filed=True)
2811
+ assert epic and epic.name == "an-epic", home
2812
+ assert model.locate_version(root, "00-shipped")
2813
+
2814
+
2815
+ def test_a_write_naming_shipped_work_says_where_it_went():
2816
+ """The refusal is the point. Every write said "no task named 'x' found" the
2817
+ moment a cut was filed — the least useful true thing to say about work that
2818
+ shipped last Thursday, and indistinguishable from a typo."""
2819
+ with tempfile.TemporaryDirectory() as tmp:
2820
+ _filed_cut(tmp)
2821
+ with _work_dir(tmp) as root:
2822
+ said = model.missing(root, "the-work")
2823
+ assert "shipped in '00-shipped'" in said, said
2824
+ assert "2026-09-11" in said, said
2825
+ # A name that genuinely does not exist keeps the plain answer: a
2826
+ # suggestion appended to a typo is noise.
2827
+ assert model.missing(root, "no-such-thing") == \
2828
+ "no task named 'no-such-thing' found"
2829
+
2830
+
2831
+ def test_shipped_work_cannot_be_moved_however_it_is_named():
2832
+ """Reads were widened; writes were not, and that is the whole care taken here.
2833
+ What a release delivered is a matter of record."""
2834
+ with tempfile.TemporaryDirectory() as tmp:
2835
+ v = _filed_cut(tmp)
2836
+ with _work_dir(tmp):
2837
+ try:
2838
+ task.cmd_move({"name": "the-work", "status": "in-progress"})
2839
+ assert False, "shipped work must not be movable"
2840
+ except SystemExit:
2841
+ pass
2842
+ assert (v / "an-epic" / "complete" / "the-work").is_dir()
2843
+ assert not (v / "an-epic" / "in-progress").exists()
2844
+
2845
+
2846
+ def test_a_cut_in_the_archive_is_not_counted_as_delivered():
2847
+ """`scan_filed` and `scan_shipped` answer different questions. A cut in
2848
+ `archive/` left the board because its work moved and there was no outcome it
2849
+ could honestly claim — counting it as delivered would be the lie archiving
2850
+ exists to avoid."""
2851
+ with tempfile.TemporaryDirectory() as tmp:
2852
+ _filed_cut(tmp, "archive", released="")
2853
+ with _work_dir(tmp) as root:
2854
+ assert [v.name for v in model.scan_filed(root)] == ["00-shipped"]
2855
+ assert model.scan_shipped(root) == []
2856
+
2857
+
2773
2858
  def test_the_completion_gate_refuses_and_says_which_gate_held():
2774
2859
  with tempfile.TemporaryDirectory() as tmp:
2775
2860
  v = _tree(tmp)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.82",
3
+ "version": "0.1.84",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -56,17 +56,17 @@
56
56
  "tsup": "^8.5.1",
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^2.1.0",
59
- "@jarvis/anthropic": "1.0.0",
60
- "@jarvis/board": "0.1.0",
61
59
  "@jarvis/agents": "1.0.0",
60
+ "@jarvis/data": "0.1.0",
62
61
  "@jarvis/errors": "1.0.0",
62
+ "@jarvis/anthropic": "1.0.0",
63
+ "@jarvis/board": "0.1.0",
63
64
  "@jarvis/logger": "1.0.0",
64
65
  "@jarvis/rpc": "1.0.0",
65
- "@jarvis/types": "1.0.0",
66
- "@jarvis/data": "0.1.0",
67
66
  "@jarvis/typescript-config": "1.0.0",
68
- "@jarvis/ui": "0.1.0",
69
- "@jarvis/vitest-config": "1.0.0"
67
+ "@jarvis/types": "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",