@appchy/jarvis 0.1.94 → 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 +1 -1
- package/harness/harness/config.py +6 -0
- package/harness/harness/epic.py +69 -1
- package/harness/harness/version.py +6 -1
- package/harness/harness/wrap.py +24 -17
- package/harness/test_work.py +75 -0
- package/harness/work.py +4 -3
- package/package.json +3 -3
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 = "
|
|
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" : ""}`;
|
|
@@ -1385,6 +1385,12 @@ def _standing_lines(where) -> list:
|
|
|
1385
1385
|
f"{shown}{more}")
|
|
1386
1386
|
for cut in where.cuts:
|
|
1387
1387
|
out.append(f"cut {cut} — every task complete, not released")
|
|
1388
|
+
# Said LAST and only beside something else. A repo git cannot read has nothing
|
|
1389
|
+
# actionable to say about commits, so on its own this would be a line every turn
|
|
1390
|
+
# that nobody can act on — while next to work in flight it is the difference
|
|
1391
|
+
# between "your code is committed" and "nobody checked".
|
|
1392
|
+
if where.loose is None and out:
|
|
1393
|
+
out.append("code in git: cannot tell — git did not answer in this directory")
|
|
1388
1394
|
return out
|
|
1389
1395
|
|
|
1390
1396
|
|
package/harness/harness/epic.py
CHANGED
|
@@ -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
|
-
|
|
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/)")
|
package/harness/harness/wrap.py
CHANGED
|
@@ -20,7 +20,7 @@ say what's actually completed or not and where are we standing"_. So the bottom
|
|
|
20
20
|
derived — the bucket, the criteria, what the completion gate would still refuse on — and
|
|
21
21
|
the prose is left to say whatever it says.
|
|
22
22
|
"""
|
|
23
|
-
from datetime import
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
from typing import NamedTuple
|
|
26
26
|
|
|
@@ -44,14 +44,12 @@ class Standing(NamedTuple):
|
|
|
44
44
|
held: list
|
|
45
45
|
#: Names taken here and moved to complete.
|
|
46
46
|
finished: list
|
|
47
|
-
#: Paths outside the board that are not in git
|
|
47
|
+
#: Paths outside the board that are not in git — None when git could not answer,
|
|
48
|
+
#: which is not the same as none of them.
|
|
48
49
|
loose: list
|
|
49
50
|
#: Cuts whose every task is complete and which nobody has released.
|
|
50
51
|
cuts: list
|
|
51
52
|
|
|
52
|
-
def anything(self) -> bool:
|
|
53
|
-
return bool(self.held or self.finished or self.loose or self.cuts)
|
|
54
|
-
|
|
55
53
|
|
|
56
54
|
#: How far back to read the board's own history when working out what THIS session
|
|
57
55
|
#: took. A session does not outlive a few days, and the log it is read out of grows
|
|
@@ -88,16 +86,24 @@ def standing(session: str, repo: Path) -> Standing:
|
|
|
88
86
|
# tree is kept; whether a session's code reached git is a question about the
|
|
89
87
|
# repo, and it is the one worth answering in a repo that has not switched the
|
|
90
88
|
# board's own commits on.
|
|
91
|
-
|
|
89
|
+
#
|
|
90
|
+
# None when git cannot answer, which is NOT the same as nothing outstanding —
|
|
91
|
+
# the list comes back empty from a repo git cannot read, and an empty list here
|
|
92
|
+
# would read as "your code is safe". The caller says which it got.
|
|
93
|
+
loose = git.uncommitted_code(repo) if git.is_repo(repo) else None
|
|
92
94
|
root, _, _ = locate_work_root()
|
|
93
95
|
if not root or not root.is_dir():
|
|
94
96
|
return Standing(held=[], finished=[], loose=loose, cuts=[])
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
# UTC, because that is the clock the rows are on: git anchors a plain date to
|
|
99
|
+
# UTC midnight and the file backend compares it as text against a UTC
|
|
100
|
+
# timestamp, so a local date would move the window by this machine's offset.
|
|
101
|
+
since = (datetime.now(timezone.utc).date()
|
|
102
|
+
- timedelta(days=_WINDOW_DAYS)).isoformat()
|
|
103
|
+
# Where it went is deliberately not kept: the bucket the item is in NOW is the
|
|
104
|
+
# status, and a remembered destination would be a second answer to it.
|
|
105
|
+
mine = {e["name"] for e in read_events(root, since=since)
|
|
106
|
+
if e.get("event") == "moved" and e.get("by") == session and e.get("name")}
|
|
101
107
|
|
|
102
108
|
held, finished = [], []
|
|
103
109
|
for name in mine:
|
|
@@ -144,8 +150,7 @@ def _uncommitted(repo) -> list:
|
|
|
144
150
|
comes back empty from a repo git cannot read — and reporting that as "nothing
|
|
145
151
|
uncommitted" is the one wrong answer this section must not give.
|
|
146
152
|
"""
|
|
147
|
-
|
|
148
|
-
if code != 0:
|
|
153
|
+
if not git.is_repo(repo):
|
|
149
154
|
return [" code in git: cannot tell — this is not a git repo, or git did not "
|
|
150
155
|
"answer. Check it yourself before you walk away."]
|
|
151
156
|
# One reader for what is outside the board, shared with the end-of-turn line and
|
|
@@ -189,10 +194,12 @@ def _in_flight(root) -> list:
|
|
|
189
194
|
# The ratio, because "move what finished" needs to know which of these is
|
|
190
195
|
# anywhere near finished, and the criteria are the only answer to that which
|
|
191
196
|
# does not depend on somebody's recollection.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
197
|
+
rows = []
|
|
198
|
+
for t in live:
|
|
199
|
+
unchecked, total = gate.criteria(t)
|
|
200
|
+
ratio = f" [{total - len(unchecked)}/{total} criteria]" if total else ""
|
|
201
|
+
rows.append(f"{t.name} — {t.title}{ratio}")
|
|
202
|
+
named = "\n ".join(rows)
|
|
196
203
|
return [f" in progress: {len(live)} item(s). Move what finished, park what did "
|
|
197
204
|
f"not — the bucket IS the status:\n {named}"]
|
|
198
205
|
|
package/harness/test_work.py
CHANGED
|
@@ -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/harness/work.py
CHANGED
|
@@ -64,9 +64,10 @@ Subcommands:
|
|
|
64
64
|
config set <key> <value> [--json] write one dotted key, validated first
|
|
65
65
|
config unset <key> drop an override, back to the default
|
|
66
66
|
context [--project <dir>] the SessionStart pointers, all config-derived
|
|
67
|
-
remind --used <tokens> [--session <id>]
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
remind [--used <tokens>] [--session <id>] where the work stands, and whether
|
|
68
|
+
to wrap up. One JSON object, or nothing. The
|
|
69
|
+
measurement is OPTIONAL and is its client's to
|
|
70
|
+
answer; the tree and git need none
|
|
70
71
|
applies --file <path> [--session <id>] what a session must be told now that
|
|
71
72
|
it is about to write this file — the judgements
|
|
72
73
|
no gate catches, and which system it is in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
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
|
},
|