leyline-code 0.8.1__py3-none-any.whl

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.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/verdicts.py ADDED
@@ -0,0 +1,390 @@
1
+ """One verdict for each task and scenario that `check` judges, and which verdicts hold up "done as agreed".
2
+
3
+ proven task: the code it names changed as it says.
4
+ scenario: its test passes after the change, and failed or did not exist before, or passed
5
+ both times while running the changed code.
6
+ partial task: some of the code it names changed, some did not.
7
+ scenario: some results that carry its name pass and some fail.
8
+ contradicted task: the code it names did not change (or what it adds is missing) while other code did.
9
+ scenario: its test fails after the change.
10
+ inconclusive task: the map cannot place the code it names, or nothing changed at all.
11
+ scenario: no pass or fail was recorded for it.
12
+ needs a person task: it names no code (docs, say). scenario: its test passes, but Leyline can tell it does not
13
+ run the changed code, or cannot tell whether it does.
14
+
15
+ By default partial, contradicted and inconclusive block; "needs a person" is listed and does not. A project can
16
+ change that in `openspec/leyline.toml`:
17
+
18
+ [check]
19
+ blocking = ["contradicted", "inconclusive", "partial"]
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ PROVEN, PARTIAL, CONTRADICTED, INCONCLUSIVE, PERSON = "proven", "partial", "contradicted", "inconclusive", "needs a person"
30
+ VERDICTS = (PROVEN, PARTIAL, CONTRADICTED, INCONCLUSIVE, PERSON)
31
+ DEFAULT_BLOCKING = (PARTIAL, CONTRADICTED, INCONCLUSIVE)
32
+ CONFIG = "leyline.toml" # in the `openspec/` folder that holds the changes
33
+ # The order the summary line counts them in, and the words it uses.
34
+ ORDER = (PROVEN, PERSON, PARTIAL, CONTRADICTED, INCONCLUSIVE)
35
+ SHORT = {PERSON: "needs you"}
36
+ ALIASES = {"needs you": PERSON, "needs person": PERSON, "person": PERSON, "proved": PROVEN, "partly": PARTIAL}
37
+
38
+
39
+ # -- config ----------------------------------------------------------------------------------------
40
+ def _toml(text: str) -> dict:
41
+ """Read TOML: tomllib where Python has it (3.11+), else a small reader for what this file needs: tables,
42
+ and keys set to a string, a number, true or false, or a list of strings (across lines too)."""
43
+ try:
44
+ import tomllib
45
+ except ImportError:
46
+ return _tiny_toml(text)
47
+ return tomllib.loads(text)
48
+
49
+
50
+ _STRING = re.compile(r'"((?:[^"\\]|\\.)*)"|\'([^\']*)\'')
51
+
52
+
53
+ def _strip_comment(line: str) -> str:
54
+ quote = None
55
+ for k, ch in enumerate(line):
56
+ if quote:
57
+ if ch == quote and (quote == "'" or line[k - 1] != "\\"):
58
+ quote = None
59
+ elif ch in "\"'":
60
+ quote = ch
61
+ elif ch == "#":
62
+ return line[:k]
63
+ return line
64
+
65
+
66
+ def _tiny_toml(text: str) -> dict:
67
+ out: dict = {}
68
+ table = out
69
+ pending = ""
70
+ for n, raw in enumerate(text.splitlines(), 1):
71
+ line = (pending + " " + _strip_comment(raw).strip()) if pending else _strip_comment(raw).strip()
72
+ if not line:
73
+ continue
74
+ if not pending and line.startswith("[") and "=" not in line:
75
+ if not line.endswith("]") or line.startswith("[["):
76
+ raise ValueError(f"line {n}: cannot read the table name {line!r}")
77
+ table = out
78
+ for part in line[1:-1].strip().split("."):
79
+ table = table.setdefault(part.strip().strip('"'), {})
80
+ continue
81
+ key, eq, value = line.partition("=")
82
+ if not eq:
83
+ raise ValueError(f"line {n}: expected `key = value`")
84
+ value = value.strip()
85
+ if value.startswith("[") and value.count("[") > value.count("]"):
86
+ pending = line # a list that goes on to the next line
87
+ continue
88
+ pending = ""
89
+ table[key.strip().strip('"')] = _tiny_value(value, n)
90
+ if pending:
91
+ raise ValueError("a list is not closed with ]")
92
+ return out
93
+
94
+
95
+ def _tiny_value(value: str, n: int):
96
+ if value.startswith("["):
97
+ if not value.endswith("]"):
98
+ raise ValueError(f"line {n}: a list is not closed with ]")
99
+ inner = value[1:-1]
100
+ if _STRING.sub("", inner).replace(",", "").strip():
101
+ raise ValueError(f"line {n}: only lists of quoted words are read here")
102
+ return [_unquote(m) for m in _STRING.finditer(inner)]
103
+ m = _STRING.fullmatch(value)
104
+ if m:
105
+ return _unquote(m)
106
+ if value in ("true", "false"):
107
+ return value == "true"
108
+ try:
109
+ return int(value)
110
+ except ValueError:
111
+ raise ValueError(f"line {n}: cannot read the value {value!r}") from None
112
+
113
+
114
+ def _unquote(m) -> str:
115
+ if m.group(1) is None:
116
+ return m.group(2) # a 'literal string': no escapes
117
+ try:
118
+ return json.loads('"' + m.group(1) + '"')
119
+ except ValueError:
120
+ return m.group(1)
121
+
122
+
123
+ def _name(word) -> Optional[str]:
124
+ w = re.sub(r"[-_\s]+", " ", str(word)).strip().lower()
125
+ w = ALIASES.get(w, w)
126
+ return w if w in VERDICTS else None
127
+
128
+
129
+ def config_path(change_dir: str | Path) -> Optional[Path]:
130
+ """`openspec/leyline.toml` for the `openspec/` folder the change sits in, when there is one."""
131
+ for p in Path(change_dir).resolve().parents:
132
+ if p.name == "openspec":
133
+ return p / CONFIG
134
+ return None
135
+
136
+
137
+ def load_config(change_dir: str | Path) -> dict:
138
+ """Which verdicts block, from the project's file or the default. Never raises: a file that cannot be read
139
+ leaves the default in place and says why in `notes`."""
140
+ out = {"blocking": list(DEFAULT_BLOCKING), "file": None, "notes": []}
141
+ path = config_path(change_dir)
142
+ if path is None or not path.is_file():
143
+ return out
144
+ shown = f"{path.parent.name}/{path.name}"
145
+ try:
146
+ data = _toml(path.read_text(encoding="utf-8"))
147
+ except (OSError, ValueError, UnicodeDecodeError) as e: # tomllib.TOMLDecodeError is a ValueError
148
+ out["notes"].append(f"{shown} could not be read ({e}), so the default applies.")
149
+ return out
150
+ section = data.get("check")
151
+ if not isinstance(section, dict) or "blocking" not in section:
152
+ return out
153
+ listed = section["blocking"]
154
+ if isinstance(listed, str):
155
+ listed = [listed]
156
+ if not isinstance(listed, list):
157
+ out["notes"].append(f"{shown}: `blocking` should be a list of verdicts, so the default applies.")
158
+ return out
159
+ blocking = []
160
+ for w in listed:
161
+ v = _name(w)
162
+ if v is None:
163
+ out["notes"].append(f"{shown} names \"{w}\", which is not a verdict; it was left out."
164
+ f" The verdicts are {', '.join(VERDICTS)}.")
165
+ elif v != PROVEN and v not in blocking:
166
+ blocking.append(v)
167
+ out["blocking"] = [v for v in VERDICTS if v in blocking]
168
+ out["file"] = shown
169
+ return out
170
+
171
+
172
+ # -- judging ---------------------------------------------------------------------------------------
173
+ def _task_verdict(t: dict, row, names, touched: set, removed: set, after_run: Optional[str]) -> tuple[str, str]:
174
+ missing = ", ".join(t["missing"][:3])
175
+ if t["state"] == "checked by you":
176
+ return PERSON, "it names no code, so the map cannot see it done"
177
+ if t.get("removal"): # a removal or rename, judged by what is left (leyline.removal)
178
+ return t["removal"]["verdict"], t["removal"]["why"]
179
+ if t["state"] == "done":
180
+ return PROVEN, "the code it names changed"
181
+ if t["state"] == "partly":
182
+ return PARTIAL, "some of the code it names changed" + (f"; not {missing}" if missing else "")
183
+ if not (touched or removed):
184
+ return INCONCLUSIVE, "nothing in the code changed since the plan; was the code mapped again after the edit?"
185
+ extra = json.loads(row["attrs"] or "{}") if row is not None else {}
186
+ ids = json.loads(row["nodes"] or "[]") if row is not None else []
187
+ new = extra.get("new", [])
188
+ # Inconclusive only when none of what it names can be placed; code that is there and unchanged contradicts it.
189
+ gone = [i for i in ids if i not in names.by_id and i not in removed]
190
+ homeless = [n for n in new if n.get("parent") and n["parent"] not in names.by_id]
191
+ base = lambda xs: {i.split("(")[0] for i in xs}
192
+ if (gone or homeless) and base(gone) == base(ids) and len(homeless) == len(new):
193
+ what = ", ".join(([i.split("(")[0].split("/")[-1] for i in gone] + [n["name"] for n in homeless])[:3])
194
+ return INCONCLUSIVE, f"the map no longer has {what}, so it cannot tell whether it changed"
195
+ if extra.get("scenarios") and not ids and not new and not after_run:
196
+ return INCONCLUSIVE, "no test results were recorded, so the test it adds cannot be seen"
197
+ action = row["action"] if row is not None else ""
198
+ if action == "remove":
199
+ return CONTRADICTED, f"{missing or 'what it removes'} is still there, unchanged, though other code changed"
200
+ if action == "add":
201
+ return CONTRADICTED, f"{missing or 'what it adds'} is missing, though other code changed"
202
+ return CONTRADICTED, f"{missing or 'the code it names'} did not change, though other code did"
203
+
204
+
205
+ def _get(r, key: str):
206
+ """A field of a result row, whether a stored row or a plain dict that may leave it out."""
207
+ try:
208
+ return r[key]
209
+ except (KeyError, IndexError):
210
+ return None
211
+
212
+
213
+ def _scenario_verdict(s: dict, after: list, before: Optional[list]) -> tuple[str, str]:
214
+ passed = [r for r in after if r["status"] == "pass"]
215
+ failed = [r for r in after if r["status"] == "fail"]
216
+ from .props import from_message # a property test's smallest failing input, when it printed one
217
+ ex = next((x for x in (from_message(_get(r, "message")) for r in failed) if x), None)
218
+ if passed and failed:
219
+ return PARTIAL, (f"{len(passed)} of {len(passed) + len(failed)} results that carry its name pass"
220
+ + (f"; one fails for {ex}" if ex else ""))
221
+ if failed:
222
+ if ex:
223
+ return CONTRADICTED, f"fails for {ex}" + ("; its test passed before the change"
224
+ if before and all(r["status"] == "pass" for r in before) else "")
225
+ if before and all(r["status"] == "pass" for r in before):
226
+ return CONTRADICTED, "its test passed before the change and fails now"
227
+ return CONTRADICTED, "its test fails after the change"
228
+ if s["state"] != "passes":
229
+ if s["state"] == "skipped":
230
+ return INCONCLUSIVE, "its test was skipped, so no pass or fail was recorded"
231
+ if s["state"] == "results older than the code":
232
+ return INCONCLUSIVE, ("its test has results, but they are older than the code: run the tests again and"
233
+ " pass their output")
234
+ if s["state"] == "no test":
235
+ return INCONCLUSIVE, "it has no test, and no result carries its name"
236
+ return INCONCLUSIVE, "no result for its test was recorded"
237
+ measured, static = s.get("measured_running_the_change"), s.get("reaches_the_change")
238
+ ran = s.get("ran_changed_code") # per-test coverage from the run after the change (leyline.affected)
239
+ if ran is False:
240
+ return PERSON, ("its test passes, but measured per-test coverage shows it never ran the changed code, so it"
241
+ " would pass whatever the change did")
242
+ # No result before: the test is new since the plan when the baseline does not hold it. One the baseline holds was
243
+ # merely left out of the run before (a subset was passed), so it is judged by whether it reaches the change.
244
+ new_test = s.get("test") is None or s.get("test_new", True)
245
+ if before is not None and (any(r["status"] == "fail" for r in before) or (not before and new_test)):
246
+ return PROVEN, ("its test failed before the change and passes now" if before
247
+ else "its test is new since the plan, and passes")
248
+ if ran or measured or (measured is None and static):
249
+ return PROVEN, "its test passes and runs the changed code" + (", as it did before" if before else "")
250
+ if measured is False or (s.get("test") and not static):
251
+ return PERSON, ("its test passes, but " + ("measured coverage shows it never runs the changed code"
252
+ if measured is False else "on the map it does not reach the changed code")
253
+ + ", so it may not test the change")
254
+ if before is None:
255
+ return PERSON, ("its test passes, but no run from before the change was recorded, and the test is not on the"
256
+ " map, so whether it tests the change cannot be told")
257
+ if not before:
258
+ return PERSON, ("its test passes, but the run before the change left it out, and it is not on the map, so"
259
+ " whether it runs the changed code cannot be told")
260
+ return PERSON, ("its test passed before the change too, and it is not on the map, so whether it runs the changed"
261
+ " code cannot be told")
262
+
263
+
264
+ def blocks(x: dict, blocking) -> bool:
265
+ """Whether an item holds up "done as agreed". A tick in tasks.md does not clear one: the agent ticks tasks as it
266
+ goes, and the verdict comes from the code and the test results, not from the agent's account of its work."""
267
+ return x.get("verdict") in blocking
268
+
269
+
270
+ def judge(con, cid: str, change_dir: str | Path, tasks: list[dict], scenarios: list[dict], names, touched: set,
271
+ removed: set, before_run: Optional[str], after_run: Optional[str]) -> dict:
272
+ """Give each task and scenario its `verdict` and `verdict_why`, in place. Returns the summary: counts, which
273
+ verdicts block, and the reasons (in the words `why_not` already uses) that hold up "done as agreed"."""
274
+ from . import spec # spec imports this module
275
+ rows = {r["key"]: r for r in con.execute("SELECT * FROM spec_items WHERE change_id = ? AND kind = 'task'", (cid,))}
276
+ for t in tasks:
277
+ t["verdict"], t["verdict_why"] = _task_verdict(t, rows.get(t["key"]), names, touched, removed, after_run)
278
+
279
+ def index(run):
280
+ if not run:
281
+ return None
282
+ return spec._results_index(con.execute("SELECT name, status, message FROM test_results WHERE run = ?", (run,)).fetchall())
283
+ after_ix, before_ix = index(after_run), index(before_run)
284
+ for s in scenarios:
285
+ after = spec._scenario_results(after_ix, s["name"]) if after_ix else []
286
+ before = spec._scenario_results(before_ix, s["name"]) if before_ix else None
287
+ s["verdict"], s["verdict_why"] = _scenario_verdict(s, after, before)
288
+
289
+ config = load_config(change_dir)
290
+ holds = []
291
+ if any(blocks(t, config["blocking"]) and t["verdict"] != PERSON for t in tasks):
292
+ holds.append("some tasks are not done")
293
+ if any(blocks(s, config["blocking"]) and s["verdict"] != PERSON for s in scenarios):
294
+ holds.append("some scenarios are not proven")
295
+ if any(blocks(x, config["blocking"]) and x["verdict"] == PERSON for x in tasks + scenarios):
296
+ holds.append(f"some items need a person, and {config['file'] or 'the project'} makes that block")
297
+ counts = {v: sum(x["verdict"] == v for x in tasks + scenarios) for v in VERDICTS}
298
+ return {"counts": counts, "blocking": config["blocking"], "config": config["file"], "notes": config["notes"],
299
+ "holds_up": holds}
300
+
301
+
302
+ # -- words -----------------------------------------------------------------------------------------
303
+ def summary_line(counts: dict) -> str:
304
+ """"7 proven, 1 needs you, 1 inconclusive"."""
305
+ return ", ".join(f"{counts[v]} {SHORT.get(v, v)}" for v in ORDER if counts.get(v))
306
+
307
+
308
+ def blocking_line(info: dict) -> str:
309
+ """"Blocking: partial, contradicted, inconclusive. Not blocking: needs you (the default)."."""
310
+ b = [SHORT.get(v, v) for v in ORDER if v in info["blocking"]]
311
+ free = [SHORT.get(v, v) for v in ORDER if v not in info["blocking"] and v != PROVEN]
312
+ return (f"Blocking: {', '.join(b) or 'nothing'}." + (f" Not blocking: {', '.join(free)}." if free else "")
313
+ + (f" (Set in {info['config']}.)" if info.get("config") else " (The default.)"))
314
+
315
+
316
+ def _item(x: dict, kind: str) -> str:
317
+ return f"task {x['key']}" if kind == "task" else f"scenario \"{x['name']}\""
318
+
319
+
320
+ def page_lines(v: dict) -> list[str]:
321
+ """The lines under the tables: the count, what blocks, and why each item that is not proven got its verdict."""
322
+ info = v.get("verdicts")
323
+ if not info:
324
+ return []
325
+ L = ["", f"Verdicts: {summary_line(info['counts']) or 'nothing to judge'}. " + blocking_line(info)]
326
+ L += [n for n in info.get("notes", [])]
327
+ rest = [(x, k) for k, xs in (("task", v["tasks"]), ("scenario", v["scenarios"])) for x in xs if x.get("verdict") != PROVEN]
328
+ if rest:
329
+ L += ["", "**Not proven, and why:**"]
330
+ L += [f"- {_item(x, k)}, {x['verdict']}: {x['verdict_why']}"
331
+ + ("" if blocks(x, info["blocking"]) else " (does not block)") for x, k in rest[:20]]
332
+ if len(rest) > 20:
333
+ L.append(f"- and {len(rest) - 20} more")
334
+ return L
335
+
336
+
337
+ def yes_text(v: dict) -> Optional[str]:
338
+ """The "Yes." sentence when something other than proven (or a task that names no code) is let through.
339
+ None leaves the usual sentence, which is right when every item is proven or is a task that names no code."""
340
+ info = v.get("verdicts")
341
+ if not info:
342
+ return None
343
+ odd = [x for x in v["tasks"] if x.get("verdict") not in (PROVEN, PERSON)] + \
344
+ [x for x in v["scenarios"] if x.get("verdict") != PROVEN]
345
+ if not odd:
346
+ return None
347
+ mine = [t["key"] for t in v["tasks"] if t.get("verdict") == PERSON]
348
+ yours = [f"\"{s['name']}\"" for s in v["scenarios"] if s.get("verdict") == PERSON]
349
+ let = [x for x in odd if x.get("verdict") != PERSON]
350
+ head = ("Nothing that blocks is left" + (f" under {info['config']}" if info.get("config") else "")
351
+ if let else "Every task " + ("that names code " if mine else "") + "is done, every scenario's test passes")
352
+ out = head + ", and nothing outside the spec changed."
353
+ if mine:
354
+ out += f" Task{'s' if len(mine) > 1 else ''} {', '.join(mine)} {'are' if len(mine) > 1 else 'is'} yours to check."
355
+ if yours:
356
+ out += (f" Scenario{'s' if len(yours) > 1 else ''} {', '.join(yours[:3])}{' and more' if len(yours) > 3 else ''}"
357
+ f" {'need' if len(yours) > 1 else 'needs'} you to confirm {'they test' if len(yours) > 1 else 'it tests'}"
358
+ " the change.")
359
+ if let:
360
+ out += f" Not proven, and let through: {summary_line({k: sum(x['verdict'] == k for x in let) for k in VERDICTS})}."
361
+ return out
362
+
363
+
364
+ def next_note(v: dict) -> str:
365
+ """For `Next:` after a check that passed: the scenarios left for the person to confirm."""
366
+ yours = [f"\"{s['name']}\"" for s in v["scenarios"] if s.get("verdict") == PERSON]
367
+ if not yours:
368
+ return ""
369
+ many = len(yours) > 1
370
+ return (f" Scenario{'s' if many else ''} {', '.join(yours[:3])}{' and more' if len(yours) > 3 else ''}"
371
+ f" {'need' if many else 'needs'} a person to confirm {'they test' if many else 'it tests'} the change:"
372
+ f" {'their tests pass' if many else 'its test passes'} but may not run the changed code.")
373
+
374
+
375
+ def waiting_lines(v: dict) -> list[str]:
376
+ """For `Next:` when the project makes a person's check block: what waits on the person, and how to clear it."""
377
+ info = v.get("verdicts") or {}
378
+ waiting = [x for x in v["tasks"] + v["scenarios"] if x.get("verdict") == PERSON and blocks(x, info.get("blocking", ()))]
379
+ mine = [x["key"] for x in waiting if "key" in x] # tasks have a key; scenarios only a name
380
+ yours = [f"\"{x['name']}\"" for x in waiting if "key" not in x]
381
+ out = []
382
+ if mine:
383
+ out.append(f"task{'s' if len(mine) > 1 else ''} {', '.join(mine)} {'name' if len(mine) > 1 else 'names'} no code,"
384
+ f" and {info.get('config') or 'the project'} makes an item that needs a person block: name the code"
385
+ f" {'they change' if len(mine) > 1 else 'it changes'} in backticks (then plan again), or take"
386
+ " \"needs a person\" out of `blocking`.")
387
+ if yours:
388
+ out.append(f"make the test for scenario{'s' if len(yours) > 1 else ''} {', '.join(yours[:3])} run the changed"
389
+ " code; it passes, but Leyline cannot see it test the change.")
390
+ return out