@appchy/jarvis 0.1.103 → 0.1.104

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
@@ -10260,7 +10260,7 @@ import { createRequire as createRequire2 } from "module";
10260
10260
  var _require = createRequire2(import.meta.url);
10261
10261
  var VERSION2 = _require("../package.json").version ?? "0.0.0";
10262
10262
  var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
10263
- var SHA = "c1072c2";
10263
+ var SHA = "9f128de";
10264
10264
  var BUILT = "2026-09-11";
10265
10265
  var BUILD = SHA ?? "source";
10266
10266
  var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
@@ -0,0 +1,94 @@
1
+ """Read the item against the repo, once per session, before the work resumes.
2
+
3
+ Every lock in this repo is on the exit. The eight verify commands, the evidence
4
+ check and the completion refusals all fire at `complete`, by which point the work is
5
+ spent — so a session that picked up a brief describing a world that ended finds out
6
+ last. This is the one door at the other end.
7
+
8
+ **It checks the ITEM, not the edit**, and that is what makes it possible at all. The
9
+ earlier framing — check the CHANGE before the first file is written — could not be
10
+ built: the map's scope call takes prose, so nothing tied a check to anything on the
11
+ board, and every way of inferring it was worse than the problem. A check whose input
12
+ is the item has the attribution for free. (Founder, 2026-09-12.)
13
+
14
+ **Once per session per item.** Not once per item: a fresh session continuing old work
15
+ is exactly when a brief has had time to go stale and nobody has looked. Not once per
16
+ session: a session that picks up a second item has read nothing about it.
17
+
18
+ **It reports; it never refuses on what it finds.** A brief can drift because somebody
19
+ else renamed a file, and making your completion wait on cleaning up their change is a
20
+ tax on whoever finishes next. What IS refused at completion is the check never having
21
+ run — the session is answerable for having looked, never for what the looking found.
22
+ (Founder, 2026-09-12.)
23
+ """
24
+
25
+ from pathlib import Path
26
+
27
+ from .config import load as load_config
28
+ from .drift import describe, drift
29
+
30
+
31
+ def _state(root: Path):
32
+ """Where the per-session record lives — the same gitignored root the wrap
33
+ reminder and the rules-delivery markers already use, reached through the same
34
+ helper so a second answer cannot drift from theirs."""
35
+ from .config import _session_state
36
+ repo = root.parent
37
+ return _session_state(load_config(repo), repo)
38
+
39
+
40
+ def _marker(root: Path, item: str, session: str) -> Path:
41
+ return _state(root) / "checked" / item / (session or "unknown")
42
+
43
+
44
+ def documents(root: Path, task) -> list:
45
+ """What this item claims: its own brief, and the epic plan it executes against.
46
+
47
+ The epic is in because a task deliberately does NOT restate the design — that
48
+ lives in `epic.md` §Plan and is what the task is built against, so a path that
49
+ died there misleads exactly as much as one in the brief.
50
+ """
51
+ docs = []
52
+ brief = task.folder / "task.md"
53
+ if brief.is_file():
54
+ docs.append(str(brief.relative_to(root.parent)))
55
+ epic = task.folder.parent.parent / "epic.md"
56
+ if epic.is_file():
57
+ docs.append(str(epic.relative_to(root.parent)))
58
+ return docs
59
+
60
+
61
+ def run(root: Path, task, session: str) -> list:
62
+ """Check the item and record that this session did. Returns the findings.
63
+
64
+ The record is written whether or not anything was found — it says the looking
65
+ happened, which is the only thing the completion gate asks about.
66
+ """
67
+ findings = drift(root.parent, documents(root, task))
68
+ marker = _marker(root, task.name, session)
69
+ try:
70
+ marker.parent.mkdir(parents=True, exist_ok=True)
71
+ marker.write_text("")
72
+ except OSError:
73
+ pass # unwritable state means the gate will ask again, which is the safe way to be wrong
74
+ return findings
75
+
76
+
77
+ def checked(root: Path, task, session: str) -> bool:
78
+ """Has THIS session read THIS item against the repo?
79
+
80
+ A session id we never got is treated as checked. The alternative is refusing
81
+ every completion that arrives without one — a plain terminal, a script, another
82
+ agent — over a record none of them could ever have written.
83
+ """
84
+ if not session:
85
+ return True
86
+ return _marker(root, task.name, session).exists()
87
+
88
+
89
+ def report(root: Path, task, session: str) -> str:
90
+ """The lines a pickup prints. Named separately from `run` so the gate can ask
91
+ whether the looking happened without printing a second copy of it."""
92
+ findings = run(root, task, session)
93
+ head = f" read {task.name} against the repo:"
94
+ return f"{head}\n{describe(findings)}"
@@ -623,6 +623,17 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
623
623
  """
624
624
  reasons = []
625
625
 
626
+ # Answerable for having LOOKED, never for what the looking found. The check
627
+ # reports drift and refuses nothing over it — a brief goes stale because of
628
+ # somebody else's rename as often as your own, and taxing whoever finishes next
629
+ # for that is the shape of defect this gate exists to stop, not to add.
630
+ from . import check as _check
631
+ from .model import current_session_id as _sid
632
+ if not _check.checked(root, task, _sid()):
633
+ reasons.append(f"this session has not read {task.name} against the repo — "
634
+ f"`jarvis work check {task.name}`. It reports what the brief "
635
+ f"names that is no longer here; nothing is refused over what it finds")
636
+
626
637
  unchecked, _ = criteria(task)
627
638
  if unchecked:
628
639
  reasons.append(f"{len(unchecked)} unchecked acceptance criterion/criteria: "
@@ -357,6 +357,17 @@ def cmd_move(args) -> int:
357
357
  events.append(root, "moved", name, **{"from": task.status, "to": to})
358
358
  print(f"moved '{name}': {task.status}/ -> {to}/")
359
359
 
360
+ # Picking it up is the moment to find out the brief describes something that is
361
+ # no longer here — cheap to act on now, and the alternative is finding out by
362
+ # walking into it. Reported, never refused: a path can die because of somebody
363
+ # else's rename, and completion is not the place to pay for that.
364
+ if to == "in-progress":
365
+ from . import check as _check
366
+ from .model import current_session_id as _sid
367
+ moved_task = locate(root, name)
368
+ if moved_task:
369
+ print(_check.report(root, moved_task, _sid()))
370
+
360
371
  # No handoff is scaffolded on pickup — `handoff.md` is created on demand via
361
372
  # `jarvis work handoff` only when a task actually hands across conversations
362
373
  # (a blank scaffold on every pickup was noise). Working checklists live in
@@ -377,6 +388,23 @@ def cmd_move(args) -> int:
377
388
 
378
389
  _sync(root)
379
390
  return 0
391
+ def cmd_check(args) -> int:
392
+ """Read an item against the repo and say where the two have come apart.
393
+
394
+ The same thing `move <name> in-progress` prints, reachable on its own — because
395
+ the completion gate asks whether this session has looked, and a gate naming a
396
+ command that does not exist is the trap this cut exists to remove.
397
+ """
398
+ from . import check as _check
399
+ root = find_work_root()
400
+ name = args["name"]
401
+ task = locate(root, name)
402
+ if not task:
403
+ die(missing(root, name))
404
+ print(_check.report(root, task, current_session_id()))
405
+ return 0
406
+
407
+
380
408
  def cmd_handoff(args) -> int:
381
409
  root = find_work_root()
382
410
  name = args["name"]
@@ -25,6 +25,12 @@ tempfile.tempdir = _TMP
25
25
 
26
26
  sys.path.insert(0, str(Path(__file__).resolve().parent))
27
27
  from harness.ids import LEDGER as L # noqa: E402 — fixtures render in the repo's dialect
28
+
29
+ # A suite that inherits the developer's own session id answers differently depending
30
+ # on who ran it — and it does now, because the completion gate asks whether THIS
31
+ # session read the item against the repo. Cleared once here; the handful of tests
32
+ # that are about session identity set it themselves and put it back.
33
+ os.environ.pop("CLAUDE_CODE_SESSION_ID", None)
28
34
  from harness import (align, architecture, autonomy, branches, config, coverage, epic, # noqa: E402
29
35
  events, extend, frontmatter, gate, generate, git, ids, kickoff,
30
36
  lint, model, peers, registry, report, safety, scaffold,
@@ -7260,9 +7266,11 @@ def test_a_finished_cut_is_ready_rather_than_planned_and_goes_back_when_reopened
7260
7266
  # Adding `ready` to the derivation took the whole README generator down
7261
7267
  # through a bare dict lookup, which is a lot of blast radius for a label.
7262
7268
  assert "ready" in generate._version_section(cut, root)
7263
- # `report` renders the same status through its own map; both now fall back
7264
- # to printing an unknown state rather than raising.
7265
- assert report is not None
7269
+ # `jarvis work list` renders the same status through its own map in
7270
+ # `report`; both now fall back to printing an unknown state rather than
7271
+ # raising. Not asserted here — it prints rather than returns, and a test
7272
+ # that captures stdout to prove a dict has a key is worth less than the
7273
+ # sentence saying both were changed together.
7266
7274
 
7267
7275
  # Reopened: straight back to current, with nothing to undo by hand.
7268
7276
  (v / "in-progress").mkdir(exist_ok=True)
@@ -7576,6 +7584,93 @@ def test_a_brief_that_still_describes_the_repo_says_so_rather_than_saying_nothin
7576
7584
  assert "still describes the repo" in describe([])
7577
7585
 
7578
7586
 
7587
+ def test_completing_asks_whether_this_session_read_the_item_against_the_repo():
7588
+ # The one thing this refuses: not having looked. Never what the looking found.
7589
+ import harness.gate as gate, harness.model as model, harness.check as check
7590
+ with tempfile.TemporaryDirectory() as tmp:
7591
+ repo = _git_repo(tmp, push=False)
7592
+ root = repo / "work"
7593
+ v = root / "versions" / "26-cut"
7594
+ t = v / "an-epic" / "in-progress" / "alpha"
7595
+ t.mkdir(parents=True)
7596
+ (v / "version.md").write_text(
7597
+ "---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
7598
+ (v / "an-epic" / "epic.md").write_text(
7599
+ "---\ntype: epic\nowner: quality\n---\n\n# An epic\n")
7600
+ (t / "task.md").write_text("---\npriority: P0\n---\n\n# Alpha\n")
7601
+ keep = os.environ.get("CLAUDE_CODE_SESSION_ID")
7602
+ os.environ["CLAUDE_CODE_SESSION_ID"] = "2222bbbb-0000-0000-0000-000000000000"
7603
+ try:
7604
+ task = model.locate(root, "alpha")
7605
+ assert any("read alpha against the repo" in r for r in gate.gate(root, task))
7606
+ check.run(root, task, "2222bbbb-0000-0000-0000-000000000000")
7607
+ assert not any("read alpha against the repo" in r for r in gate.gate(root, task))
7608
+ finally:
7609
+ if keep is None: os.environ.pop("CLAUDE_CODE_SESSION_ID", None)
7610
+ else: os.environ["CLAUDE_CODE_SESSION_ID"] = keep
7611
+
7612
+
7613
+ def test_a_second_session_on_the_same_item_is_asked_again():
7614
+ # The drift case the founder named: continuing old work in a new session is
7615
+ # exactly when a brief has had time to go stale and nobody has looked.
7616
+ import harness.model as model, harness.check as check
7617
+ with tempfile.TemporaryDirectory() as tmp:
7618
+ repo = _git_repo(tmp, push=False)
7619
+ root = repo / "work"
7620
+ v = root / "versions" / "26-cut"
7621
+ t = v / "an-epic" / "in-progress" / "alpha"
7622
+ t.mkdir(parents=True)
7623
+ (v / "version.md").write_text(
7624
+ "---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
7625
+ (v / "an-epic" / "epic.md").write_text(
7626
+ "---\ntype: epic\nowner: quality\n---\n\n# An epic\n")
7627
+ (t / "task.md").write_text("---\npriority: P0\n---\n\n# Alpha\n")
7628
+ task = model.locate(root, "alpha")
7629
+ check.run(root, task, "first-session")
7630
+ assert check.checked(root, task, "first-session")
7631
+ assert not check.checked(root, task, "second-session")
7632
+
7633
+
7634
+ def test_a_caller_with_no_session_is_not_refused_over_a_record_it_could_never_write():
7635
+ # A plain terminal, a script, another agent. Refusing those would be refusing
7636
+ # everything that is not Claude Code over a marker none of them can leave.
7637
+ import harness.model as model, harness.check as check
7638
+ with tempfile.TemporaryDirectory() as tmp:
7639
+ repo = _git_repo(tmp, push=False)
7640
+ root = repo / "work"
7641
+ v = root / "versions" / "26-cut"
7642
+ t = v / "an-epic" / "in-progress" / "alpha"
7643
+ t.mkdir(parents=True)
7644
+ (v / "version.md").write_text(
7645
+ "---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
7646
+ (v / "an-epic" / "epic.md").write_text(
7647
+ "---\ntype: epic\nowner: quality\n---\n\n# An epic\n")
7648
+ (t / "task.md").write_text("---\npriority: P0\n---\n\n# Alpha\n")
7649
+ assert check.checked(root, model.locate(root, "alpha"), "")
7650
+
7651
+
7652
+ def test_the_check_reads_the_epic_plan_too_not_only_the_brief():
7653
+ # A task deliberately does not restate the design — it lives in epic.md §Plan and
7654
+ # is what the task is built against, so a path that died there misleads as much.
7655
+ import harness.model as model, harness.check as check
7656
+ with tempfile.TemporaryDirectory() as tmp:
7657
+ repo = _git_repo(tmp, push=False)
7658
+ root = repo / "work"
7659
+ v = root / "versions" / "26-cut"
7660
+ e = v / "an-epic"
7661
+ (e / "in-progress" / "alpha").mkdir(parents=True)
7662
+ (v / "version.md").write_text(
7663
+ "---\ncreated: 2026-09-01\norder: 26\noutcome: a user can do it\n---\n\n# A cut\n")
7664
+ (e / "epic.md").write_text("---\ntype: epic\nowner: quality\n---\n\n"
7665
+ "# An epic\n\n## Plan\n\nbuilt on `src/long-gone.ts`\n")
7666
+ (e / "in-progress" / "alpha" / "task.md").write_text(
7667
+ "---\npriority: P0\n---\n\n# Alpha\n")
7668
+ _git(repo, "add", "-A"); _git(repo, "commit", "-m", "seed")
7669
+
7670
+ found = check.run(root, model.locate(root, "alpha"), "s1")
7671
+ assert [f[2] for f in found] == ["src/long-gone.ts"]
7672
+
7673
+
7579
7674
  if __name__ == "__main__":
7580
7675
  tests = [v for k, v in sorted(globals().items())
7581
7676
  if k.startswith("test_") and callable(v)]
package/harness/work.py CHANGED
@@ -135,7 +135,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
135
135
 
136
136
  from harness.tree import die, locate_work_root
137
137
  from harness import events, git
138
- from harness.task import cmd_code, cmd_handoff, cmd_move, cmd_new, cmd_path, cmd_place, cmd_plan, cmd_session
138
+ from harness.task import cmd_check, cmd_code, cmd_handoff, cmd_move, cmd_new, cmd_path, cmd_place, cmd_plan, cmd_session
139
139
  from harness.epic import cmd_epic_new
140
140
  from harness.version import cmd_archive, cmd_release, cmd_version_new
141
141
  from harness.product import cmd_feature_new
@@ -167,7 +167,7 @@ SUBCOMMANDS = (
167
167
  "find", "list", "readme", "move", "plan", "session", "kickoff", "path", "code",
168
168
  "domain-new", "system-new", "where", "rules", "align", "wrap", "coverage",
169
169
  "migrate", "next", "status", "drop", "ask", "answer", "needs", "verify",
170
- "observed", "log", "digest", "sync",
170
+ "observed", "log", "digest", "sync", "check",
171
171
  )
172
172
 
173
173
 
@@ -450,6 +450,11 @@ def dispatch(cmd, pos, flags, cfg) -> int:
450
450
  if not pos:
451
451
  die("usage: jarvis work handoff <name>")
452
452
  return cmd_handoff({"name": pos[0]})
453
+ if cmd == "check":
454
+ if not pos:
455
+ die("usage: jarvis work check <name> "
456
+ "(reads the item's brief and its epic plan against the repo)")
457
+ return cmd_check({"name": pos[0]})
453
458
  if cmd == "release":
454
459
  if not pos:
455
460
  die("usage: jarvis work release <v>")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.103",
3
+ "version": "0.1.104",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -59,12 +59,12 @@
59
59
  "@jarvis/agents": "1.0.0",
60
60
  "@jarvis/anthropic": "1.0.0",
61
61
  "@jarvis/board": "0.1.0",
62
- "@jarvis/logger": "1.0.0",
63
- "@jarvis/errors": "1.0.0",
64
62
  "@jarvis/data": "0.1.0",
63
+ "@jarvis/errors": "1.0.0",
65
64
  "@jarvis/rpc": "1.0.0",
66
- "@jarvis/typescript-config": "1.0.0",
67
65
  "@jarvis/types": "1.0.0",
66
+ "@jarvis/logger": "1.0.0",
67
+ "@jarvis/typescript-config": "1.0.0",
68
68
  "@jarvis/ui": "0.1.0",
69
69
  "@jarvis/vitest-config": "1.0.0"
70
70
  },