@appchy/jarvis 0.1.113 → 0.1.115
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 +20 -10
- package/dist/bin.js.map +1 -1
- package/harness/harness/align.py +9 -6
- package/harness/harness/epic.py +84 -56
- package/harness/harness/links.py +114 -0
- package/harness/harness/report.py +44 -0
- package/harness/harness/shift.py +4 -3
- package/harness/harness/version.py +5 -5
- package/harness/presets/appchy/PRESET.md +12 -9
- package/harness/test_work.py +155 -31
- package/harness/work.py +8 -3
- package/package.json +5 -5
package/harness/test_work.py
CHANGED
|
@@ -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
|
|
510
|
-
#
|
|
511
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
960
|
-
#
|
|
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,
|
|
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
|
-
|
|
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
|
|
|
@@ -2051,6 +2076,103 @@ def test_a_finished_cut_leaves_the_board_without_pretending_it_shipped():
|
|
|
2051
2076
|
os.environ.pop("WORK_DIR", None)
|
|
2052
2077
|
|
|
2053
2078
|
|
|
2079
|
+
def test_a_standing_broken_link_is_settled_by_depth_or_by_board_name():
|
|
2080
|
+
# The set `repair()` refuses on purpose: broken BEFORE any move, so there is no
|
|
2081
|
+
# before-state and identity cannot answer. Two resolvers instead — the tail is
|
|
2082
|
+
# right and the `../` count is wrong, or the item moved bucket and the folder it
|
|
2083
|
+
# lives in is named in the link.
|
|
2084
|
+
from harness import links
|
|
2085
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2086
|
+
base = Path(tmp) / "work"
|
|
2087
|
+
(base / "product").mkdir(parents=True)
|
|
2088
|
+
(base / "product" / "board.md").write_text("# board\n")
|
|
2089
|
+
moved = base / "versions" / "a-cut" / "an-epic" / "complete" / "shipped-thing"
|
|
2090
|
+
moved.mkdir(parents=True)
|
|
2091
|
+
(moved / "task.md").write_text("# shipped thing\n")
|
|
2092
|
+
|
|
2093
|
+
doc = base / "versions" / "a-cut" / "an-epic" / "queue" / "live-one" / "task.md"
|
|
2094
|
+
doc.parent.mkdir(parents=True)
|
|
2095
|
+
doc.write_text(
|
|
2096
|
+
# two hops short of product/
|
|
2097
|
+
"governed by [board](../../../product/board.md), "
|
|
2098
|
+
# names a bucket the item has left
|
|
2099
|
+
"after [shipped](../shipped-thing/task.md).\n")
|
|
2100
|
+
|
|
2101
|
+
fixed, stuck = links.repair_dangling(base, apply=True)
|
|
2102
|
+
assert (fixed, stuck) == (2, []), (fixed, stuck)
|
|
2103
|
+
body = doc.read_text()
|
|
2104
|
+
assert "](../../../../../product/board.md)" in body, body
|
|
2105
|
+
assert "](../../complete/shipped-thing/task.md)" in body, body
|
|
2106
|
+
assert links.dangling(base) == []
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def test_a_broken_link_two_resolvers_disagree_about_is_left_for_a_person():
|
|
2110
|
+
# A link repaired to the wrong real file is worse than one that is visibly
|
|
2111
|
+
# broken: nothing afterwards can tell it was ever wrong.
|
|
2112
|
+
from harness import links
|
|
2113
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2114
|
+
base = Path(tmp) / "work"
|
|
2115
|
+
# The SAME tail exists at two heights on one walk up, so counting `../`
|
|
2116
|
+
# cannot choose between them.
|
|
2117
|
+
(base / "notes").mkdir(parents=True)
|
|
2118
|
+
(base / "notes" / "shared.md").write_text("# the outer one\n")
|
|
2119
|
+
(base / "one" / "notes").mkdir(parents=True)
|
|
2120
|
+
(base / "one" / "notes" / "shared.md").write_text("# the inner one\n")
|
|
2121
|
+
doc = base / "one" / "notes" / "deep" / "doc.md"
|
|
2122
|
+
doc.parent.mkdir(parents=True)
|
|
2123
|
+
doc.write_text("see [it](../../../../notes/shared.md)\n")
|
|
2124
|
+
|
|
2125
|
+
fixed, stuck = links.repair_dangling(base, apply=True)
|
|
2126
|
+
assert fixed == 0, "a link two candidates answer must not be rewritten"
|
|
2127
|
+
assert [h for _, h in stuck] == ["../../../../notes/shared.md"]
|
|
2128
|
+
assert "](../../../../notes/shared.md)" in doc.read_text(), "it was rewritten"
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
def test_a_link_inside_a_code_span_is_prose_and_never_a_finding():
|
|
2132
|
+
# A doc teaching the syntax writes `](../…md)` in backticks. The live false
|
|
2133
|
+
# positive this check ever produced was a quoted anchor whose span opened on one
|
|
2134
|
+
# line and closed on the next, so the span has to cross newlines.
|
|
2135
|
+
from harness import links
|
|
2136
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2137
|
+
base = Path(tmp) / "work"
|
|
2138
|
+
base.mkdir(parents=True)
|
|
2139
|
+
(base / "doc.md").write_text(
|
|
2140
|
+
"Resolving every `](../…md)` in the tree.\n\n"
|
|
2141
|
+
"The anchor reads `[sessions](sessions.md)'s AC-03 and the test\n"
|
|
2142
|
+
"lands later`, which is a quotation.\n\n"
|
|
2143
|
+
"But [this one](../really-gone.md) is a link.\n")
|
|
2144
|
+
|
|
2145
|
+
broken = links.dangling(base)
|
|
2146
|
+
assert [h for _, h, _ in broken] == ["../really-gone.md"], broken
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
def test_the_links_gate_blocks_on_a_live_brief_and_never_on_a_shipped_cut():
|
|
2150
|
+
# Blocking is only safe because the steady state is zero — every folder move
|
|
2151
|
+
# repairs its own links — and because a shipped cut is allowed to name things
|
|
2152
|
+
# that have since been deleted.
|
|
2153
|
+
import contextlib, io
|
|
2154
|
+
from harness import report
|
|
2155
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2156
|
+
root = Path(tmp) / "work"
|
|
2157
|
+
shipped = root / "versions" / "complete" / "01-done" / "an-epic"
|
|
2158
|
+
shipped.mkdir(parents=True)
|
|
2159
|
+
(shipped / "epic.md").write_text("gone: [src](../../../../src/deleted.ts)\n")
|
|
2160
|
+
os.environ["WORK_DIR"] = str(root)
|
|
2161
|
+
try:
|
|
2162
|
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
|
2163
|
+
assert report.cmd_links({}) == 0, "a shipped cut must not block"
|
|
2164
|
+
assert "never blocking" in out.getvalue()
|
|
2165
|
+
|
|
2166
|
+
live = root / "versions" / "02-open" / "an-epic" / "queue" / "a-task"
|
|
2167
|
+
live.mkdir(parents=True)
|
|
2168
|
+
(live / "task.md").write_text("see [rule](../../../../nowhere.md)\n")
|
|
2169
|
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
|
2170
|
+
assert report.cmd_links({}) == 1, "a live broken link must block"
|
|
2171
|
+
assert "nowhere.md" in out.getvalue()
|
|
2172
|
+
finally:
|
|
2173
|
+
os.environ.pop("WORK_DIR", None)
|
|
2174
|
+
|
|
2175
|
+
|
|
2054
2176
|
def test_links_are_repaired_by_what_they_pointed_at_not_by_what_they_say():
|
|
2055
2177
|
from harness import links
|
|
2056
2178
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -6394,7 +6516,8 @@ def test_an_epic_whose_plan_doc_is_gone_still_moves_into_a_cut():
|
|
|
6394
6516
|
def test_releasing_a_cut_holding_an_epic_with_no_plan_doc_still_releases_it():
|
|
6395
6517
|
# The unlink ran on every epic, so one without a plan doc raised — and it raised
|
|
6396
6518
|
# AFTER `released:` was stamped, leaving the cut reading as released while every
|
|
6397
|
-
# other epic
|
|
6519
|
+
# other epic went untouched. The stamp has the same shape of hazard: an epic with
|
|
6520
|
+
# nothing to stamp must be skipped rather than read.
|
|
6398
6521
|
with tempfile.TemporaryDirectory() as tmp:
|
|
6399
6522
|
root = Path(tmp)
|
|
6400
6523
|
v = _tree(tmp)
|
|
@@ -6412,9 +6535,10 @@ def test_releasing_a_cut_holding_an_epic_with_no_plan_doc_still_releases_it():
|
|
|
6412
6535
|
|
|
6413
6536
|
after = model.locate_version(root, "26-cut")
|
|
6414
6537
|
assert after.released, "the cut never got its released stamp"
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6538
|
+
plans = list(after.folder.rglob("epic.md"))
|
|
6539
|
+
assert len(plans) == 1, "the epic that had a plan should still have exactly one"
|
|
6540
|
+
assert plans[0].parent.name == "publish-under-your-name"
|
|
6541
|
+
assert epic._RELEASED_MARKER in plans[0].read_text(), "the kept plan is unstamped"
|
|
6418
6542
|
|
|
6419
6543
|
|
|
6420
6544
|
def test_a_shipped_criterion_delivered_by_a_filed_cut_is_still_covered():
|
|
@@ -7403,13 +7527,13 @@ def test_the_stop_hook_being_switched_off_silences_the_standing_too():
|
|
|
7403
7527
|
|
|
7404
7528
|
|
|
7405
7529
|
|
|
7406
|
-
def
|
|
7407
|
-
# `release` has always printed "promote still-load-bearing decisions to the
|
|
7408
|
-
#
|
|
7409
|
-
# Measured on 01-one-board: twelve plans, 450,405 bytes,
|
|
7410
|
-
# statements with no owner file
|
|
7411
|
-
#
|
|
7412
|
-
#
|
|
7530
|
+
def test_a_release_says_what_it_is_stamping_shut():
|
|
7531
|
+
# `release` has always printed "promote still-load-bearing decisions to the domain
|
|
7532
|
+
# that owns them", and until 2026-09-12 it printed that AFTER unlinking the files
|
|
7533
|
+
# holding those decisions. Measured on 01-one-board: twelve plans, 450,405 bytes,
|
|
7534
|
+
# about a hundred durable statements with no owner file. The plans are now kept
|
|
7535
|
+
# and stamped rather than deleted, so what the notice owes a reader changed with
|
|
7536
|
+
# it: not a last call before the bytes go, but the argument for the sweep.
|
|
7413
7537
|
with tempfile.TemporaryDirectory() as tmp:
|
|
7414
7538
|
try:
|
|
7415
7539
|
repo = _git_repo(tmp, push=False)
|
|
@@ -7432,22 +7556,22 @@ def test_a_release_says_what_it_is_deleting_before_it_deletes_it():
|
|
|
7432
7556
|
with _work_dir(str(repo / "work")):
|
|
7433
7557
|
said = _capture_stdout(lambda: version.cmd_release({"name": "09-a-cut"}))
|
|
7434
7558
|
|
|
7435
|
-
#
|
|
7436
|
-
assert "
|
|
7559
|
+
# What is going quiet, in front of the person, in units they can weigh.
|
|
7560
|
+
assert "STAMPING 1 epic plan(s)" in said, said
|
|
7437
7561
|
assert "bytes" in said
|
|
7438
7562
|
# WHICH sections can outlive the plan — and not the ones that cannot.
|
|
7439
7563
|
assert "§Governance this implies" in said, said
|
|
7440
7564
|
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
7565
|
|
|
7444
|
-
# And the claim is true: the plan is
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
assert "a rule is owed" in
|
|
7566
|
+
# And the claim is true: the plan is still there, and stamped.
|
|
7567
|
+
plan = v / "an-epic" / "epic.md"
|
|
7568
|
+
assert plan.is_file(), "a released plan is kept, not deleted"
|
|
7569
|
+
assert "a rule is owed" in plan.read_text()
|
|
7570
|
+
assert epic._RELEASED_MARKER in plan.read_text()
|
|
7448
7571
|
|
|
7449
|
-
#
|
|
7450
|
-
assert "no longer in the tree" in said, said
|
|
7572
|
+
# Nothing sends a reader to git for the text any more, because it is here.
|
|
7573
|
+
assert "no longer in the tree" not in said, said
|
|
7574
|
+
assert head not in said or "recoverable" not in said, said
|
|
7451
7575
|
finally:
|
|
7452
7576
|
events._PENDING.clear()
|
|
7453
7577
|
config.apply(config.DEFAULTS)
|
package/harness/work.py
CHANGED
|
@@ -77,6 +77,9 @@ Subcommands:
|
|
|
77
77
|
list --branch <ref> the board as another branch has it (read-only)
|
|
78
78
|
rules --task <name> what constrains a task, derived
|
|
79
79
|
align [--class <name>] every misalignment class; REPORT-ONLY, exit 0
|
|
80
|
+
links [--fix] board links that point at nothing. Exits 1 when a
|
|
81
|
+
LIVE brief or governance file holds one, so a gate
|
|
82
|
+
can block; history is reported and never blocks
|
|
80
83
|
wrap [--task <name>] finish a session cleanly: what a machine knows
|
|
81
84
|
about this run, then what to do. Writes NOTHING
|
|
82
85
|
coverage [--feature <name>] what a RUN proved vs what features promise
|
|
@@ -143,7 +146,7 @@ from harness import ids
|
|
|
143
146
|
from harness.architecture import (cmd_domain_new, cmd_init, cmd_rules, cmd_system_new,
|
|
144
147
|
cmd_where)
|
|
145
148
|
from harness.coverage import cmd_coverage
|
|
146
|
-
from harness.report import cmd_align, cmd_list, cmd_readme
|
|
149
|
+
from harness.report import cmd_align, cmd_links, cmd_list, cmd_readme
|
|
147
150
|
from harness.wrap import cmd_wrap
|
|
148
151
|
from harness.config import (DEFAULTS, ConfigError, apply, cmd_applies, cmd_config,
|
|
149
152
|
cmd_context, cmd_method, cmd_remind, resolve)
|
|
@@ -167,7 +170,7 @@ SUBCOMMANDS = (
|
|
|
167
170
|
"find", "list", "readme", "move", "plan", "session", "kickoff", "path", "code",
|
|
168
171
|
"domain-new", "system-new", "where", "rules", "align", "wrap", "coverage",
|
|
169
172
|
"migrate", "next", "status", "drop", "ask", "answer", "needs", "verify",
|
|
170
|
-
"observed", "log", "digest", "sync", "check",
|
|
173
|
+
"observed", "log", "digest", "sync", "check", "links",
|
|
171
174
|
)
|
|
172
175
|
|
|
173
176
|
|
|
@@ -350,7 +353,7 @@ def _say(note: str) -> None:
|
|
|
350
353
|
#: remembering to come back here.
|
|
351
354
|
_READS = {"list", "find", "path", "code", "where", "rules", "status", "digest",
|
|
352
355
|
"log", "needs", "align", "coverage", "readme", "config", "context",
|
|
353
|
-
"doctor", "kickoff", "remind", "applies", "wrap"}
|
|
356
|
+
"doctor", "kickoff", "remind", "applies", "wrap", "links"}
|
|
354
357
|
|
|
355
358
|
|
|
356
359
|
def _reads_only(cmd: str, flags: dict) -> None:
|
|
@@ -514,6 +517,8 @@ def dispatch(cmd, pos, flags, cfg) -> int:
|
|
|
514
517
|
return cmd_rules({**flags, "task": flags.get("task") or (pos[0] if pos else None)})
|
|
515
518
|
if cmd == "align":
|
|
516
519
|
return cmd_align(flags)
|
|
520
|
+
if cmd == "links":
|
|
521
|
+
return cmd_links(flags)
|
|
517
522
|
if cmd == "wrap":
|
|
518
523
|
return cmd_wrap(cfg, flags, _project_root(flags))
|
|
519
524
|
if cmd == "coverage":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.115",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -57,14 +57,14 @@
|
|
|
57
57
|
"typescript": "^5.7.0",
|
|
58
58
|
"vitest": "^2.1.0",
|
|
59
59
|
"@jarvis/agents": "1.0.0",
|
|
60
|
-
"@jarvis/
|
|
60
|
+
"@jarvis/anthropic": "1.0.0",
|
|
61
61
|
"@jarvis/board": "0.1.0",
|
|
62
|
+
"@jarvis/data": "0.1.0",
|
|
63
|
+
"@jarvis/errors": "1.0.0",
|
|
62
64
|
"@jarvis/logger": "1.0.0",
|
|
65
|
+
"@jarvis/types": "1.0.0",
|
|
63
66
|
"@jarvis/rpc": "1.0.0",
|
|
64
|
-
"@jarvis/anthropic": "1.0.0",
|
|
65
67
|
"@jarvis/typescript-config": "1.0.0",
|
|
66
|
-
"@jarvis/data": "0.1.0",
|
|
67
|
-
"@jarvis/types": "1.0.0",
|
|
68
68
|
"@jarvis/ui": "0.1.0",
|
|
69
69
|
"@jarvis/vitest-config": "1.0.0"
|
|
70
70
|
},
|