@appchy/jarvis 0.1.132 → 0.1.133
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 +20 -0
- package/harness/harness/epic.py +4 -0
- package/harness/harness/lint.py +12 -1
- package/harness/harness/shift.py +10 -2
- package/harness/harness/task.py +10 -0
- package/harness/presets/appchy/PRESET.md +10 -1
- package/harness/schema/work.config.schema.json +19 -0
- package/harness/test_work.py +195 -0
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -10402,7 +10402,7 @@ import { createRequire as createRequire2 } from "module";
|
|
|
10402
10402
|
var _require = createRequire2(import.meta.url);
|
|
10403
10403
|
var VERSION2 = _require("../package.json").version ?? "0.0.0";
|
|
10404
10404
|
var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
|
|
10405
|
-
var SHA = "
|
|
10405
|
+
var SHA = "9b15888";
|
|
10406
10406
|
var BUILT = "2026-09-12";
|
|
10407
10407
|
var BUILD = SHA ?? "source";
|
|
10408
10408
|
var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
|
|
@@ -167,6 +167,16 @@ DEFAULTS = {
|
|
|
167
167
|
# interpolated. A repo may run anything it likes; what it may not do is get a
|
|
168
168
|
# `;` to mean `;`.
|
|
169
169
|
"verify": {},
|
|
170
|
+
# How much open work an epic and a cut may hold before the board says so. Counted in
|
|
171
|
+
# OPEN tasks, because what is hard to plan together or to close is what is still owed.
|
|
172
|
+
# Neither is a gate: an epic past its ceiling warns and a written `one_goal:` answers
|
|
173
|
+
# it; a cut past its ceiling is flagged as owing a reshape and nothing is refused or
|
|
174
|
+
# routed. The numbers are a repo's own — a harness serving repos of very different
|
|
175
|
+
# sizes cannot know them — and these are only what a repo that sets nothing gets.
|
|
176
|
+
"ceilings": {
|
|
177
|
+
"epic": 8,
|
|
178
|
+
"cut": 15,
|
|
179
|
+
},
|
|
170
180
|
"autonomy": {
|
|
171
181
|
# The highest `tier:` an unattended run acts on ALONE. Default 2 — a
|
|
172
182
|
# scheduled shift does ordinary work and stops at the irreversible.
|
|
@@ -458,6 +468,12 @@ def _validate(cfg: dict) -> None:
|
|
|
458
468
|
f"without a shell (shell=False), so shell operators would be "
|
|
459
469
|
f"passed as literal arguments. Put the composition in a script "
|
|
460
470
|
f"and call that.")
|
|
471
|
+
for key in ("epic", "cut"):
|
|
472
|
+
value = cfg["ceilings"][key]
|
|
473
|
+
# `bool` is an `int` in Python, and `true` is not a number of tasks.
|
|
474
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
475
|
+
raise ConfigError(f"ceilings.{key} must be a whole number of open tasks, 1 or more "
|
|
476
|
+
f"(got {value!r}) — 0 would flag every {key} that holds any open work")
|
|
461
477
|
ceiling = cfg["autonomy"]["ceiling"]
|
|
462
478
|
if not isinstance(ceiling, int) or isinstance(ceiling, bool) or not 0 <= ceiling <= 3:
|
|
463
479
|
raise ConfigError("autonomy.ceiling must be an integer 0–3 (the highest "
|
|
@@ -650,6 +666,10 @@ def apply(cfg: dict) -> None:
|
|
|
650
666
|
gate.VERIFY = dict(cfg["verify"])
|
|
651
667
|
task.PLANS_DIR = cfg["plans"]["dir"]
|
|
652
668
|
autonomy.CEILING = cfg["autonomy"]["ceiling"]
|
|
669
|
+
# Read through `tree` by the lint and the model, never imported by name, so binding it
|
|
670
|
+
# here is what makes a repo's own number the one in force.
|
|
671
|
+
tree.EPIC_TASK_CEILING = cfg["ceilings"]["epic"]
|
|
672
|
+
tree.CUT_TASK_CEILING = cfg["ceilings"]["cut"]
|
|
653
673
|
# `shift` reads the ceiling it was imported with, so bind it there too — a
|
|
654
674
|
# module-level `from … import CEILING` captures the value, not the name.
|
|
655
675
|
shift.CEILING = cfg["autonomy"]["ceiling"]
|
package/harness/harness/epic.py
CHANGED
|
@@ -6,6 +6,7 @@ from .frontmatter import rewrite_file
|
|
|
6
6
|
from .model import epic_home, locate_epic, locate_version
|
|
7
7
|
from .scaffold import _check_covers_ref, _check_kebab, _check_unused, _scaffold_epic
|
|
8
8
|
from .generate import _sync
|
|
9
|
+
from .lint import say_ceiling
|
|
9
10
|
from . import events, links
|
|
10
11
|
|
|
11
12
|
|
|
@@ -210,6 +211,9 @@ def cmd_epic_move(args) -> int:
|
|
|
210
211
|
)
|
|
211
212
|
print(f"pulled epic '{name}' ({was}) -> {rel(dest, root)} "
|
|
212
213
|
f"with {len(moved)} task(s)")
|
|
214
|
+
# A whole epic arriving is the likeliest way a cut goes past its ceiling in one
|
|
215
|
+
# move, so it is told exactly as filing or placing a single task is.
|
|
216
|
+
say_ceiling(root, version.name)
|
|
213
217
|
_sync(root)
|
|
214
218
|
return 0
|
|
215
219
|
#: Headings whose content is a STATEMENT ABOUT THE WORLD rather than a plan for one
|
package/harness/harness/lint.py
CHANGED
|
@@ -13,7 +13,7 @@ from .tree import (
|
|
|
13
13
|
)
|
|
14
14
|
from .frontmatter import as_list, parse_frontmatter, split_frontmatter
|
|
15
15
|
from . import tree as _tree
|
|
16
|
-
from .model import locate_feature, scan, scan_features, scan_filed
|
|
16
|
+
from .model import locate_feature, locate_version, scan, scan_features, scan_filed
|
|
17
17
|
from .registry import code_vocabulary, locate_domain
|
|
18
18
|
|
|
19
19
|
#: The MCP server this repo names as its graph engine, bound by `config.apply`.
|
|
@@ -591,6 +591,17 @@ def ceiling_note(version) -> str:
|
|
|
591
591
|
f"its version.md")
|
|
592
592
|
|
|
593
593
|
|
|
594
|
+
def say_ceiling(root, version_name: str) -> None:
|
|
595
|
+
"""Tell whoever just added work to a cut that it is past its ceiling, if it is.
|
|
596
|
+
|
|
597
|
+
One home for every command that lands work in a cut — filing a task, placing one,
|
|
598
|
+
moving a whole epic — so a new way in cannot forget to say it."""
|
|
599
|
+
landed = locate_version(root, version_name)
|
|
600
|
+
note = ceiling_note(landed) if landed else ""
|
|
601
|
+
if note:
|
|
602
|
+
print(f"note: {note}")
|
|
603
|
+
|
|
604
|
+
|
|
594
605
|
def print_lint(root: Path):
|
|
595
606
|
for w in lint_warnings(root):
|
|
596
607
|
print(f" WARN {w}", file=sys.stderr)
|
package/harness/harness/shift.py
CHANGED
|
@@ -27,6 +27,7 @@ from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
|
|
|
27
27
|
from .frontmatter import rewrite_file
|
|
28
28
|
from .model import locate, record_session, scan
|
|
29
29
|
from .generate import _sync
|
|
30
|
+
from .lint import ceiling_note
|
|
30
31
|
from .epic import plans_held
|
|
31
32
|
from . import autonomy, events, holders, links, peers
|
|
32
33
|
# The ceiling is read through the MODULE, never bound in with `from … import`.
|
|
@@ -251,7 +252,14 @@ def cmd_status(args) -> int:
|
|
|
251
252
|
# every epic plan and cannot be undone — so this announces and never acts, and it
|
|
252
253
|
# quotes the price so the decision is made with it visible rather than after.
|
|
253
254
|
ready = [v for v in s["versions"] if v.finishable()]
|
|
254
|
-
|
|
255
|
+
# A cut past its ceiling owes a reshape, and where its work goes is a person's call —
|
|
256
|
+
# so it waits on them HERE, beside a finished cut nobody has closed, rather than only
|
|
257
|
+
# scrolling past as a warning after some unrelated write. Same sentence as the
|
|
258
|
+
# warning, from the one function that words it.
|
|
259
|
+
full = [v for v in live if v.over_ceiling()]
|
|
260
|
+
print(f"WAITING ON YOU ({len(waiting) + len(ready) + len(full)})")
|
|
261
|
+
for v in full:
|
|
262
|
+
print(f" {ceiling_note(v)}")
|
|
255
263
|
for v in ready:
|
|
256
264
|
plans, size, held = plans_held(v)
|
|
257
265
|
cost = ""
|
|
@@ -264,7 +272,7 @@ def cmd_status(args) -> int:
|
|
|
264
272
|
for t, q in waiting[:8]:
|
|
265
273
|
parts = q.split(" ")
|
|
266
274
|
print(f" {t.name}: {' '.join(parts[2:]) if len(parts) > 2 else q}")
|
|
267
|
-
if not waiting and not ready:
|
|
275
|
+
if not waiting and not ready and not full:
|
|
268
276
|
print(" nothing — the shift is not blocked on you.")
|
|
269
277
|
|
|
270
278
|
# One read of the record, shared by every section below. It is a `git log`
|
package/harness/harness/task.py
CHANGED
|
@@ -15,6 +15,7 @@ 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
|
|
17
17
|
from .generate import _sync
|
|
18
|
+
from .lint import say_ceiling
|
|
18
19
|
from .gate import delivery_gate, report_gate
|
|
19
20
|
from .autonomy import _open_questions, derive_tier
|
|
20
21
|
from . import events
|
|
@@ -122,6 +123,12 @@ def cmd_new(args) -> int:
|
|
|
122
123
|
epic=epic.name)
|
|
123
124
|
print(f"created {rel(folder / 'task.md', root)} ({home})")
|
|
124
125
|
print(f" tier {tier} — {tier_why}")
|
|
126
|
+
# Filing into a full cut is never refused and never re-routed: where this task
|
|
127
|
+
# belongs is a planning call. What the caller is owed is the fact, addressed to
|
|
128
|
+
# them as a `note:` — which a door relaying the harness's words hands to an agent,
|
|
129
|
+
# rather than leaving it in the board-wide warnings nobody filing over a tool sees.
|
|
130
|
+
if version_name:
|
|
131
|
+
say_ceiling(root, version_name)
|
|
125
132
|
_sync(root)
|
|
126
133
|
return 0
|
|
127
134
|
def cmd_place(args) -> int:
|
|
@@ -198,6 +205,9 @@ def cmd_place(args) -> int:
|
|
|
198
205
|
if restored:
|
|
199
206
|
events.append(root, "restored", name, to=f"{version.name}/{epic.name}")
|
|
200
207
|
print(f"{'restored' if restored else 'placed'} '{name}' -> {rel(dest, root)}")
|
|
208
|
+
# Placing is a person arranging a cut on purpose, and a pre-planned cut is legal —
|
|
209
|
+
# so it is told, never stopped, exactly as filing is.
|
|
210
|
+
say_ceiling(root, version.name)
|
|
201
211
|
_sync(root)
|
|
202
212
|
return 0
|
|
203
213
|
|
|
@@ -329,7 +329,16 @@ design is done ONCE for a whole goal; at one or two tasks there is no "whole goa
|
|
|
329
329
|
- **Under the floor with real depth behind it → cut the tasks, don't merge.** An epic whose Plan lists
|
|
330
330
|
five slices and has one folder is *under-cut*. An epic with **zero** tasks is the worst case: a goal
|
|
331
331
|
nobody has cut work for has not been planned, only wished.
|
|
332
|
-
- **The floor is a floor,
|
|
332
|
+
- **The floor is a floor, and there is a ceiling too.** Past **8 open tasks** an epic warns: it is no
|
|
333
|
+
longer a goal anybody plans together. Split it along its goals, or say why its size is deliberate in
|
|
334
|
+
`one_goal:`. Open tasks only — a long-running epic whose work has mostly shipped is fine at any size.
|
|
335
|
+
(This bullet used to say "a long-running epic at nine tasks is fine", which the ceiling made false.)
|
|
336
|
+
- **A cut has a ceiling of 15 open tasks, summed across its epics, and a full cut is FLAGGED — never
|
|
337
|
+
refused, never routed.** Filing into one still lands where you filed it; the board then says a
|
|
338
|
+
reshape is owed until somebody resolves it — a later cut, or a reshaped epic — so the cut that closes
|
|
339
|
+
is not a half-built one. **That call is a person's: an agent parks it rather than moving work.** A cut
|
|
340
|
+
planned big on purpose says why in `one_goal:` in its `version.md`. The number of epics is not
|
|
341
|
+
bounded, only the sum. Both numbers are a repo's to set, under `ceilings` in its config.
|
|
333
342
|
|
|
334
343
|
**Merging epics is prose work, not folder work.** Moving the folders takes one `mv`; what matters is
|
|
335
344
|
that the surviving `epic.md` absorbs every settled call from the ones being dissolved *before* their
|
|
@@ -185,6 +185,25 @@
|
|
|
185
185
|
"default": {},
|
|
186
186
|
"description": "Commands run to verify work, by name — e.g. {\"test\": \"<this repo's test command>\"}. Free-form because toolchains differ, and naming one here would ship a bias. EXECUTED by `work verify` and required by the completion gate: a task cannot reach complete/ without a passing record. Each is split with shlex and run with shell=False, so shell operators (&&, ||, ;, |, >, $(), backticks) are REFUSED at load rather than passed to the first binary as literal arguments — put the composition in a script and call that."
|
|
187
187
|
},
|
|
188
|
+
"ceilings": {
|
|
189
|
+
"type": "object",
|
|
190
|
+
"additionalProperties": false,
|
|
191
|
+
"description": "How much OPEN work an epic and a cut may hold before the board says so. Neither is a gate: an epic past its ceiling warns and a written `one_goal:` answers it; a cut past its ceiling is flagged as owing a reshape — move work to a later cut, or reshape an epic — and nothing is refused or routed, because where a task belongs is a person's call.",
|
|
192
|
+
"properties": {
|
|
193
|
+
"epic": {
|
|
194
|
+
"type": "integer",
|
|
195
|
+
"minimum": 1,
|
|
196
|
+
"default": 8,
|
|
197
|
+
"description": "Open tasks an epic may hold before it warns that it is no longer a goal anybody plans together."
|
|
198
|
+
},
|
|
199
|
+
"cut": {
|
|
200
|
+
"type": "integer",
|
|
201
|
+
"minimum": 1,
|
|
202
|
+
"default": 15,
|
|
203
|
+
"description": "Open tasks a cut may hold, summed across its epics, before it is flagged as owing a reshape. The number of epics is not bounded, only this sum."
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
188
207
|
"autonomy": {
|
|
189
208
|
"type": "object",
|
|
190
209
|
"additionalProperties": false,
|
package/harness/test_work.py
CHANGED
|
@@ -6645,6 +6645,10 @@ def test_every_shipped_default_is_mechanics_or_nothing():
|
|
|
6645
6645
|
# On by default like its two siblings, and for their reason: a hook the
|
|
6646
6646
|
# harness ships is its own mechanics, not a fact about somebody's setup.
|
|
6647
6647
|
"hooks.pre_tool_use.enabled",
|
|
6648
|
+
# How big the harness's own tiers may get before it says so — its model, like
|
|
6649
|
+
# the autonomy ceiling beside it. The numbers were chosen against live counts
|
|
6650
|
+
# across four boards, and a repo that disagrees sets its own.
|
|
6651
|
+
"ceilings.epic", "ceilings.cut",
|
|
6648
6652
|
}
|
|
6649
6653
|
shipped = set()
|
|
6650
6654
|
|
|
@@ -9005,6 +9009,197 @@ def test_the_readme_never_shows_an_archived_item():
|
|
|
9005
9009
|
finally:
|
|
9006
9010
|
os.environ.pop("WORK_DIR")
|
|
9007
9011
|
|
|
9012
|
+
|
|
9013
|
+
# ---------------------------------------------------------------------------
|
|
9014
|
+
# A cut has a ceiling — and an epic does too.
|
|
9015
|
+
#
|
|
9016
|
+
# Nothing bounded how much a board may describe. mixbrix filed 73 items in one day
|
|
9017
|
+
# and reached 690; this repo's own cut 02 reached 24 open. Founder's calls,
|
|
9018
|
+
# 2026-09-12: count OPEN tasks, 8 per epic and 15 per cut by default, per repo. An
|
|
9019
|
+
# epic past its ceiling warns and a written `one_goal:` answers it. A cut past its
|
|
9020
|
+
# ceiling is FLAGGED as owing a reshape — never refused and never routed, because
|
|
9021
|
+
# where a task belongs is a planning call: "it should be resolved and either be in a
|
|
9022
|
+
# new cut or the epic, depends on the task … to make sure we dont land a half baked
|
|
9023
|
+
# cut".
|
|
9024
|
+
# ---------------------------------------------------------------------------
|
|
9025
|
+
|
|
9026
|
+
|
|
9027
|
+
def _open_epic(v: Path, name: str, queued: int = 0, done: int = 0, one_goal: str = ""):
|
|
9028
|
+
e = _epic(v, name, owner="quality", one_goal=one_goal)
|
|
9029
|
+
for bucket, n in (("queue", queued), ("complete", done)):
|
|
9030
|
+
for i in range(n):
|
|
9031
|
+
_task(e / bucket, f"{name}-{bucket}-{i}")
|
|
9032
|
+
return e
|
|
9033
|
+
|
|
9034
|
+
|
|
9035
|
+
def _ceiling_warnings(root: Path, needle: str) -> list:
|
|
9036
|
+
return [w for w in lint.lint_warnings(root) if needle in w]
|
|
9037
|
+
|
|
9038
|
+
|
|
9039
|
+
def test_an_epic_warns_only_once_it_is_past_its_ceiling_of_open_tasks():
|
|
9040
|
+
# AT the ceiling is legal; one past it is not. Asserted side by side, because an
|
|
9041
|
+
# off-by-one here is the difference between a guard and a nag.
|
|
9042
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9043
|
+
v = _tree(tmp, "26-cut")
|
|
9044
|
+
_open_epic(v, "at-the-ceiling", queued=8)
|
|
9045
|
+
_open_epic(v, "one-past-it", queued=9)
|
|
9046
|
+
warned = _ceiling_warnings(Path(tmp), "open task(s)")
|
|
9047
|
+
assert not any("at-the-ceiling" in w for w in warned), warned
|
|
9048
|
+
assert any("one-past-it" in w for w in warned), warned
|
|
9049
|
+
|
|
9050
|
+
|
|
9051
|
+
def test_finished_work_does_not_count_against_an_epic():
|
|
9052
|
+
# An epic whose work has mostly shipped is not hard to plan together. Counting
|
|
9053
|
+
# finished tasks would warn on every long-lived goal about work nobody is still
|
|
9054
|
+
# doing — which is why this bound counts OPEN tasks while the floor counts all.
|
|
9055
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9056
|
+
v = _tree(tmp, "26-cut")
|
|
9057
|
+
_open_epic(v, "mostly-shipped", queued=3, done=20)
|
|
9058
|
+
assert not _ceiling_warnings(Path(tmp), "open task(s)")
|
|
9059
|
+
|
|
9060
|
+
|
|
9061
|
+
def test_an_epic_that_wrote_why_its_size_is_deliberate_is_not_warned():
|
|
9062
|
+
# The same written escape the floor and the region cap already honour — not a
|
|
9063
|
+
# fourth mechanism. An empty one would exempt nothing, as it does for those.
|
|
9064
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9065
|
+
v = _tree(tmp, "26-cut")
|
|
9066
|
+
_open_epic(v, "big-on-purpose", queued=12, one_goal="one migration, cut in steps")
|
|
9067
|
+
assert not _ceiling_warnings(Path(tmp), "open task(s)")
|
|
9068
|
+
|
|
9069
|
+
|
|
9070
|
+
def test_a_cut_is_flagged_when_its_SUMMED_open_tasks_pass_its_ceiling():
|
|
9071
|
+
# Two epics of eight and seven: neither warns on its own, and the cut is exactly
|
|
9072
|
+
# full. One more open task anywhere and the cut owes a reshape. Epic COUNT is not
|
|
9073
|
+
# bounded — only the sum — so splitting an epic can never make this worse.
|
|
9074
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9075
|
+
root = Path(tmp)
|
|
9076
|
+
v = _tree(tmp, "26-cut")
|
|
9077
|
+
_open_epic(v, "half-a", queued=8)
|
|
9078
|
+
_open_epic(v, "half-b", queued=7)
|
|
9079
|
+
assert model.locate_version(root, "26-cut").over_ceiling() == 0
|
|
9080
|
+
_open_epic(v, "one-more", queued=1, done=5)
|
|
9081
|
+
assert model.locate_version(root, "26-cut").over_ceiling() == 1
|
|
9082
|
+
assert len(_ceiling_warnings(root, "past its ceiling")) == 1
|
|
9083
|
+
assert not _ceiling_warnings(root, "open task(s)"), "no single epic is past 8"
|
|
9084
|
+
|
|
9085
|
+
|
|
9086
|
+
def _full_cut(v: Path) -> Path:
|
|
9087
|
+
"""Two epics at exactly their ceiling of eight: neither warns on its own, and the
|
|
9088
|
+
cut is one past its ceiling of fifteen. Returns the first, to file into."""
|
|
9089
|
+
first = _open_epic(v, "a", queued=8)
|
|
9090
|
+
_open_epic(v, "b", queued=8)
|
|
9091
|
+
return first
|
|
9092
|
+
|
|
9093
|
+
|
|
9094
|
+
def test_the_cut_flag_names_both_ways_out_and_whose_call_it_is():
|
|
9095
|
+
# A flag that only says "too many" leaves the reader to invent the remedy, and the
|
|
9096
|
+
# remedy an agent invents is moving work around — exactly the call that is not
|
|
9097
|
+
# its to make.
|
|
9098
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9099
|
+
_full_cut(_tree(tmp, "26-cut"))
|
|
9100
|
+
[flag] = _ceiling_warnings(Path(tmp), "past its ceiling")
|
|
9101
|
+
assert "later cut" in flag and "reshape an epic" in flag, flag
|
|
9102
|
+
assert "person's call" in flag, flag
|
|
9103
|
+
|
|
9104
|
+
|
|
9105
|
+
def test_filing_into_a_full_cut_is_never_refused_and_never_rerouted():
|
|
9106
|
+
# The founder's call in one assertion: the item lands exactly where it was filed.
|
|
9107
|
+
# A refusal mid-sweep is what sessions route around, and a silent reroute decides
|
|
9108
|
+
# which half of a goal ships.
|
|
9109
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9110
|
+
full = _full_cut(_tree(tmp, "26-cut"))
|
|
9111
|
+
with _work_dir(tmp) as root:
|
|
9112
|
+
assert model.locate_version(root, "26-cut").over_ceiling() == 1
|
|
9113
|
+
assert task.cmd_new({"name": "one-more-thing", "epic": "a"}) == 0
|
|
9114
|
+
assert (full / "queue" / "one-more-thing" / "task.md").is_file()
|
|
9115
|
+
assert model.locate_version(root, "26-cut").over_ceiling() == 2
|
|
9116
|
+
|
|
9117
|
+
|
|
9118
|
+
def test_placing_a_whole_epic_into_a_full_cut_is_told_too():
|
|
9119
|
+
# A whole epic arriving is the likeliest way a cut goes past its ceiling in one
|
|
9120
|
+
# move, and it runs through a different command from filing a single task.
|
|
9121
|
+
import io, contextlib
|
|
9122
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9123
|
+
_full_cut(_tree(tmp, "26-cut"))
|
|
9124
|
+
later = Path(tmp) / "versions" / "27-next"
|
|
9125
|
+
later.mkdir(parents=True)
|
|
9126
|
+
(later / "version.md").write_text(
|
|
9127
|
+
"---\ncreated: 2026-08-01\norder: 27\noutcome: y\n---\n\n# Next\n")
|
|
9128
|
+
_open_epic(later, "arriving", queued=2)
|
|
9129
|
+
out = io.StringIO()
|
|
9130
|
+
with _work_dir(tmp), contextlib.redirect_stdout(out):
|
|
9131
|
+
assert task.cmd_place({"name": "arriving", "version": "26-cut"}) == 0
|
|
9132
|
+
assert "note: cut 26-cut: 18 open tasks, 3 past its ceiling" in out.getvalue(), \
|
|
9133
|
+
out.getvalue()
|
|
9134
|
+
|
|
9135
|
+
|
|
9136
|
+
def test_a_cut_that_wrote_why_its_size_is_deliberate_is_not_flagged():
|
|
9137
|
+
# Planning a big release in advance stays legal. What the flag exists for is
|
|
9138
|
+
# accretion, and the difference is recorded rather than guessed.
|
|
9139
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9140
|
+
root = Path(tmp)
|
|
9141
|
+
v = _tree(tmp, "26-cut")
|
|
9142
|
+
md = v / "version.md"
|
|
9143
|
+
md.write_text(md.read_text().replace(
|
|
9144
|
+
"---\n\n# Cut", 'one_goal: "planned as one release on purpose"\n---\n\n# Cut'))
|
|
9145
|
+
_full_cut(v)
|
|
9146
|
+
assert model.locate_version(root, "26-cut").over_ceiling() == 0
|
|
9147
|
+
assert not _ceiling_warnings(root, "past its ceiling")
|
|
9148
|
+
|
|
9149
|
+
|
|
9150
|
+
def test_a_released_cut_owes_no_reshape():
|
|
9151
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9152
|
+
_full_cut(_tree(tmp, "26-cut", released="2026-09-01"))
|
|
9153
|
+
assert model.locate_version(Path(tmp), "26-cut").over_ceiling() == 0
|
|
9154
|
+
|
|
9155
|
+
|
|
9156
|
+
def test_list_and_the_readme_show_a_full_cut_beside_its_status():
|
|
9157
|
+
# Being full is not a status — a cut can be planned or current and still owe a
|
|
9158
|
+
# reshape — so it is printed beside the badge rather than replacing it.
|
|
9159
|
+
import io, contextlib
|
|
9160
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9161
|
+
_full_cut(_tree(tmp, "26-cut"))
|
|
9162
|
+
out = io.StringIO()
|
|
9163
|
+
with _work_dir(tmp) as root:
|
|
9164
|
+
with contextlib.redirect_stdout(out):
|
|
9165
|
+
report.cmd_list({})
|
|
9166
|
+
generate._sync(root)
|
|
9167
|
+
assert "over its ceiling by 1" in out.getvalue(), out.getvalue()
|
|
9168
|
+
assert "Over its ceiling by 1" in (root / "README.md").read_text()
|
|
9169
|
+
|
|
9170
|
+
|
|
9171
|
+
def test_a_repo_sets_its_own_ceilings_and_every_reader_honours_them():
|
|
9172
|
+
# The harness serves repos of very different sizes, so the numbers are config.
|
|
9173
|
+
# Read THROUGH THE MODULE: a `from .tree import` would capture the shipped default
|
|
9174
|
+
# and quietly ignore the repo's own, which is the drift the harness keeps finding.
|
|
9175
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9176
|
+
root = Path(tmp)
|
|
9177
|
+
_full_cut(_tree(tmp, "26-cut"))
|
|
9178
|
+
assert model.locate_version(root, "26-cut").over_ceiling() == 1
|
|
9179
|
+
cfg = copy.deepcopy(config.DEFAULTS)
|
|
9180
|
+
cfg["ceilings"] = {"epic": 5, "cut": 20}
|
|
9181
|
+
config.apply(cfg)
|
|
9182
|
+
try:
|
|
9183
|
+
assert model.locate_version(root, "26-cut").over_ceiling() == 0
|
|
9184
|
+
assert len(_ceiling_warnings(root, "open task(s)")) == 2, "8 open is past a ceiling of 5"
|
|
9185
|
+
finally:
|
|
9186
|
+
config.apply(config.DEFAULTS)
|
|
9187
|
+
|
|
9188
|
+
|
|
9189
|
+
def test_a_ceiling_must_be_a_positive_whole_number():
|
|
9190
|
+
# A ceiling of 0 flags every cut with any work in it, and a string never compares
|
|
9191
|
+
# — so it is refused at load, naming the key, rather than discovered later as a
|
|
9192
|
+
# board that warns about everything or about nothing.
|
|
9193
|
+
for key in ("epic", "cut"):
|
|
9194
|
+
for bad in (0, -1, "15", 1.5, True):
|
|
9195
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
9196
|
+
repo = _repo_with_config(tmp, {"ceilings": {key: bad}})
|
|
9197
|
+
try:
|
|
9198
|
+
config.load(repo)
|
|
9199
|
+
assert False, f"accepted ceilings.{key} = {bad!r}"
|
|
9200
|
+
except config.ConfigError as e:
|
|
9201
|
+
assert f"ceilings.{key}" in str(e) and f"every {key} " in str(e), e
|
|
9202
|
+
|
|
9008
9203
|
if __name__ == "__main__":
|
|
9009
9204
|
tests = [v for k, v in sorted(globals().items())
|
|
9010
9205
|
if k.startswith("test_") and callable(v)]
|