@troylar/sightline 1.120.3 → 1.175.0

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/CONTRIBUTING.md CHANGED
@@ -10,10 +10,15 @@ on Windows use a Bash environment with those commands available.
10
10
 
11
11
  ```sh
12
12
  npm ci
13
+ npx playwright install chromium
13
14
  ./build.sh
14
15
  ./test/run.sh
15
16
  ```
16
17
 
18
+ The suite includes five layout checks in a real browser, and it fails if Playwright isn't installed, so a local pass
19
+ means the same as CI's. To skip them on purpose, run `SIGHTLINE_NO_BROWSER=1 ./test/run.sh`; the run still says they
20
+ were skipped.
21
+
17
22
  Edit **src/sightline.template.html**, never generated **dist/** files. Build before testing: harnesses execute
18
23
  the built HTML. See AGENTS.md for focused checks, deterministic dates and the exact runner requirements.
19
24
 
@@ -28,7 +28,7 @@ import time
28
28
 
29
29
  # The Sightline page this copy matches. build.sh stamps it into the copy it publishes beside the page; None means this
30
30
  # runs from source, where nothing is stamped and nothing is compared.
31
- PAGE_VERSION = "1.120.3"
31
+ PAGE_VERSION = "1.175.0"
32
32
  CONTEXT_FILE = "sightline-assistant-view.json"
33
33
  AMENDMENTS_FILE = "sightline-amendments.json"
34
34
  RESULTS_FILE = "sightline-assistant-results.json"
@@ -208,7 +208,7 @@ def check_offline(folder: Path, batch: dict) -> list:
208
208
  bad.append(f"{where}: {k} isn't a valid {'date (YYYY-MM-DD)' if t.startswith('iso') else t.rstrip('?')}.")
209
209
  if name.endswith(".add") and "id" in spec and not (isinstance(args.get("id"), str) and args["id"]):
210
210
  bad.append(f"{where}: give the new item an id (like risk-vendor-limits), so reading the file again can't add it twice.")
211
- if str(name).startswith("jira."):
211
+ if str(name).startswith("jira.") and name not in ("jira.hide", "jira.unhide"): # hiding isn't a placement
212
212
  bad.extend(f"{where}: {x}" for x in check_jira_place(ctx.get("jiraSort") or {}, name, args))
213
213
  return bad
214
214
 
@@ -223,21 +223,33 @@ def check_jira_place(sort: dict, name: str, args: dict) -> list:
223
223
  pile = {up(x.get("key")): x for x in sort.get("issues") or [] if isinstance(x, dict)}
224
224
  placed = {up(k): v for k, v in (sort.get("placed") or {}).items() if isinstance(v, dict)}
225
225
  epics = {up(x.get("key")) for x in sort.get("epics") or [] if isinstance(x, dict)}
226
+ epic_on = {up(x.get("key")): (x.get("state"), x.get("on")) for x in sort.get("epics") or [] if isinstance(x, dict)}
226
227
  targets = sort.get("targets") or {}
227
- lanes = {x.get("id") for x in targets.get("workstreams") or [] if isinstance(x, dict)}
228
+ lane_of = {} # a workstream's id, or a former id it keeps (was) -> its id now
229
+ for x in targets.get("workstreams") or []:
230
+ if isinstance(x, dict) and isinstance(x.get("id"), str):
231
+ lane_of[x["id"]] = x["id"]
232
+ for o in x.get("was") if isinstance(x.get("was"), list) else []:
233
+ lane_of.setdefault(o, x["id"])
234
+ lanes = set(lane_of)
228
235
  bars = {x.get("id"): x for x in targets.get("initiatives") or [] if isinstance(x, dict)}
229
236
  bad = []
230
237
  if name in ("jira.epicLink", "jira.epicNew"):
231
238
  epic = up(args.get("epic"))
232
239
  if epic not in epics:
233
240
  bad.append(f"{epic[:40] or 'epic'} isn't an epic in jiraSort.epics.")
241
+ state, on = epic_on.get(epic, (None, None))
242
+ lane_on = next((x.get("lane") for x in targets.get("initiatives") or [] if isinstance(x, dict) and x.get("id") == on), None)
243
+ here = state == "on" and (on == args.get("id") if name == "jira.epicLink" else lane_on is not None and lane_on == args.get("lane") and args.get("id") in (None, "", on)) # already there: done, as the page says
244
+ if on and not here and (name == "jira.epicNew" or state == "on"): # one initiative per epic; a parked one moves on a link
245
+ bad.append(f"{epic[:40]} is already on {str(on)[:40]} (jiraSort.epics), so it wouldn't be moved.")
234
246
  else:
235
247
  keys = list(dict.fromkeys(up(k) for k in args.get("keys"))) if isinstance(args.get("keys"), list) else []
236
248
  if not keys:
237
249
  bad.append("list at least one Jira key in keys.")
238
250
  if len(keys) > JIRA_KEYS_MAX:
239
251
  bad.append(f"one change places up to {JIRA_KEYS_MAX} issues.")
240
- dest = ("notOurs", None) if name == "jira.notOurs" else ("lane", args.get("lane")) if name == "jira.toWorkstream" else ("initiative", args.get("id"))
252
+ dest = ("notOurs", None) if name == "jira.notOurs" else ("lane", lane_of.get(args.get("lane"), args.get("lane"))) if name == "jira.toWorkstream" else ("initiative", args.get("id"))
241
253
  for k in keys:
242
254
  if k in pile:
243
255
  continue
@@ -71,7 +71,7 @@ BATCH = 50 # keys per JQL qu
71
71
  MAX_PLAN_BYTES = 5 * 1024 * 1024
72
72
  # The Sightline page this copy matches. build.sh stamps it into the copy it publishes beside the page; None means this
73
73
  # runs from source, where nothing is stamped and nothing is compared.
74
- PAGE_VERSION = "1.120.3"
74
+ PAGE_VERSION = "1.175.0"
75
75
  CONTEXT_FILE = "sightline-assistant-view.json"
76
76
  PLAN_SCHEMA = 2 # the plan format this script reads; must equal SCHEMA in the page (a test checks)
77
77
  STATUS_FORMAT = 1 # the formats this script writes; the page refuses files newer than it knows (a test checks)
@@ -1809,21 +1809,25 @@ ACTIONS = {
1809
1809
  "initiative.setScope": ({"id": "id", "scope": "str"}, True),
1810
1810
  "initiative.delivers.add": ({"id": "id", "outcome": "id"}, False),
1811
1811
  "initiative.delivers.remove": ({"id": "id", "outcome": "id"}, False),
1812
- "task.add": ({"id": "id?", "bar": "id", "start": "iso", "days": "int?", "label": "str?", "owners": "ids?", "tags": "ids?", "story": "str?"}, False),
1812
+ "task.add": ({"id": "id?", "bar": "id", "start": "iso?", "after": "ids?", "days": "int?", "rough": "bool?", "label": "str?", "owners": "ids?", "tags": "ids?", "story": "str?"}, False),
1813
1813
  "task.set": ({"id": "id", "field": "str", "value": "any"}, True),
1814
1814
  "task.setStatus": ({"id": "id", "status": "str"}, True),
1815
- "task.move": ({"id": "id", "start": "iso?", "days": "int?"}, True),
1815
+ "task.move": ({"id": "id", "start": "isoOrAfter?", "days": "int?"}, True),
1816
1816
  "task.moveTo": ({"id": "id", "bar": "id"}, True),
1817
1817
  "task.link": ({"id": "id", "after": "id"}, False),
1818
1818
  "task.unlink": ({"id": "id", "after": "id"}, False),
1819
1819
  "task.del": ({"id": "id"}, False),
1820
- "outcome.add": ({"id": "id?", "name": "str", "source": "str?", "target": "str?", "release": "id?", "date": "iso?", "jira": "keys?"}, False),
1820
+ "outcome.add": ({"id": "id?", "name": "str", "source": "str?", "target": "str?", "release": "id?", "date": "iso?", "jira": "keys?", "capture": "str?", "askedBy": "who?", "raised": "iso?", "lane": "id?"}, False),
1821
1821
  "outcome.set": ({"id": "id", "field": "str", "value": "any"}, True),
1822
1822
  "outcome.setTarget": ({"id": "id", "target": "str?", "date": "iso?"}, True),
1823
1823
  "outcome.setRelease": ({"id": "id", "release": "idOrBlank"}, True),
1824
1824
  "outcome.linkJira": ({"id": "id", "keys": "keys"}, False),
1825
1825
  "outcome.unlinkJira": ({"id": "id", "key": "str"}, False),
1826
1826
  "outcome.drop": ({"id": "id", "reason": "str?"}, False),
1827
+ "outcome.handoff": ({"id": "id", "initiative": "id?", "jira": "str?"}, True),
1828
+ "outcome.reopen": ({"id": "id"}, False),
1829
+ "outcome.raiseAgain": ({"id": "id", "ask": "id", "who": "who?", "raised": "iso?", "source": "str?"}, False),
1830
+ "outcome.unraise": ({"id": "id", "ask": "id"}, False),
1827
1831
  "outcome.del": ({"id": "id"}, True),
1828
1832
  "link.add": ({"id": "id", "after": "id"}, False),
1829
1833
  "link.remove": ({"id": "id", "after": "id"}, False),
@@ -1836,6 +1840,7 @@ ACTIONS = {
1836
1840
  "workstream.setReserve": ({"id": "id", "reserve": "any"}, True),
1837
1841
  "workstream.set": ({"id": "id", "field": "str", "value": "str"}, True),
1838
1842
  "workstream.move": ({"id": "id", "index": "int"}, True),
1843
+ "workstream.reid": ({"lane": "id", "id": "id"}, True),
1839
1844
  "workstream.del": ({"id": "id"}, False),
1840
1845
  "guide.set": ({"section": "str", "value": "str"}, True),
1841
1846
  "guide.setWorkstream": ({"lane": "id", "value": "str"}, True),
@@ -1845,6 +1850,7 @@ ACTIONS = {
1845
1850
  "skill.remove": ({"id": "id"}, False),
1846
1851
  "meeting.set": ({"id": "id", "field": "str", "value": "str"}, True),
1847
1852
  "person.add": ({"id": "id?", "name": "str", "role": "str?", "days": "int?", "outside": "any?"}, False),
1853
+ "group.add": ({"id": "id?", "name": "str", "members": "ids?"}, False),
1848
1854
  "person.set": ({"id": "id", "field": "str", "value": "any"}, True),
1849
1855
  "person.setAlloc": ({"id": "id", "alloc": "any"}, True),
1850
1856
  "person.setSplit": ({"id": "id", "lanes": "weights"}, True),
@@ -1872,6 +1878,9 @@ ACTIONS = {
1872
1878
  "jira.notOurs": ({"keys": "keys"}, False),
1873
1879
  "jira.epicLink": ({"epic": "str", "id": "id"}, False),
1874
1880
  "jira.epicNew": ({"epic": "str", "lane": "id", "id": "id?"}, False),
1881
+ "jira.hide": ({"keys": "keys", "why": "str?"}, False),
1882
+ "jira.unhide": ({"keys": "keys"}, False),
1883
+ "cards.set": ({"field": "str", "value": "any"}, True),
1875
1884
  "risk.add": ({"id": "id?", "text": "str", "sev": "any?", "state": "any?", "owners": "any?", "item": "any?", "next": "any?", "waitingOn": "any?", "escalateBy": "any?", "aiHint": "any?"}, False),
1876
1885
  "risk.set": ({"id": "id", "field": "str", "value": "any"}, True),
1877
1886
  "risk.del": ({"id": "id"}, False),
@@ -1892,6 +1901,8 @@ def _act_type_ok(t: str, v) -> bool:
1892
1901
  return isinstance(v, str) and bool(ACT_ID.match(v))
1893
1902
  if t == "isoOrNull":
1894
1903
  return v is None or _act_type_ok("iso", v)
1904
+ if t == "isoOrAfter":
1905
+ return v == "after" or _act_type_ok("iso", v)
1895
1906
  if t == "idOrBlank":
1896
1907
  return v == "" or _act_type_ok("id", v)
1897
1908
  if t == "ids":
@@ -1902,17 +1913,41 @@ def _act_type_ok(t: str, v) -> bool:
1902
1913
  return isinstance(v, (int, float)) and not isinstance(v, bool) and v == v and abs(v) != float("inf")
1903
1914
  if t == "int":
1904
1915
  return isinstance(v, int) and not isinstance(v, bool)
1916
+ if t == "bool":
1917
+ return isinstance(v, bool)
1905
1918
  if t == "iso":
1906
- return _valid_iso(v)
1919
+ return _valid_iso(v) or _rel_day(v)
1907
1920
  if t == "keys":
1908
1921
  return isinstance(v, list) and len(v) <= 500 and all(isinstance(k, str) and len(k) <= 40 for k in v)
1909
1922
  if t == "any":
1910
- return v is None or isinstance(v, bool) or _act_type_ok("num", v) or _act_type_ok("str", v) or _act_type_ok("ids", v)
1923
+ return v is None or isinstance(v, bool) or _act_type_ok("num", v) or _act_type_ok("str", v) or _act_type_ok("ids", v) or _act_type_ok("who", v)
1924
+ if t == "who": # who asked for a captured item: {"type": "person" or "group", "id": ...}
1925
+ return v is None or (isinstance(v, dict) and set(v) == {"type", "id"} and v["type"] in ("person", "group") and isinstance(v["id"], str) and bool(re.match(r"^[A-Za-z0-9._-]{1,60}$", v["id"])))
1911
1926
  if t == "weights":
1912
1927
  return v is None or (isinstance(v, dict) and len(v) <= 20 and all(_act_type_ok("id", k) and _act_type_ok("int", x) for k, x in v.items()))
1913
1928
  return False
1914
1929
 
1915
1930
 
1931
+ _REL_DAY = re.compile(r"^(today|tomorrow|yesterday|(\+ ?|in )\d{1,3} ?(d|days?|w|wks?|weeks?)|- ?\d{1,3} ?(d|days?|w|wks?|weeks?)|\d{1,3} (days?|weeks?) ago"
1932
+ r"|((this|next|last) )?(mon|tue|wed|thu|fri|sat|sun)[a-z]{0,6}|(next|last) (week|month)|(this|next) sprint)$")
1933
+ _REL_WEEKDAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
1934
+
1935
+
1936
+ def _rel_day(v) -> bool:
1937
+ """A day said in words (next Monday, +3d, 2 weeks ago...): the page reads it against today when it checks the change."""
1938
+ if not isinstance(v, str) or len(v) > 40:
1939
+ return False
1940
+ t = " ".join(v.strip().lower().split())
1941
+ if not _REL_DAY.match(t):
1942
+ return False
1943
+ m = re.match(r"^((this|next|last) )?([a-z]+)$", t) # a weekday: the start of its name, at least three letters
1944
+ if m and m.group(3) == "month":
1945
+ return m.group(2) in ("next", "last")
1946
+ if m and m.group(3) not in ("today", "tomorrow", "yesterday", "week", "sprint"):
1947
+ return any(d.startswith(m.group(3)) for d in _REL_WEEKDAYS)
1948
+ return True
1949
+
1950
+
1916
1951
  def _valid_iso(v) -> bool:
1917
1952
  if not isinstance(v, str) or not re.match(r"^\d{4}-\d{2}-\d{2}$", v):
1918
1953
  return False
@@ -1950,15 +1985,23 @@ def check_action_op(o, at: str, ids: dict, made: dict) -> str:
1950
1985
  made.setdefault("bar", set()).add(args["id"])
1951
1986
  if creates and "id" in spec and not (isinstance(args.get("id"), str) and args["id"]):
1952
1987
  return f"{at}: give the new item an id (like risk-vendor-limits), so reading the file again can't add it twice"
1988
+ if name == "task.add" and "start" not in args and "after" not in args:
1989
+ return f"{at}: task.add needs a start, or after: the tasks it starts after."
1990
+ if name == "task.add" and isinstance(args.get("after"), list) and args.get("id") and args["id"] in args["after"]:
1991
+ return f"{at}: a task can't start after itself."
1992
+ if name == "task.add" and args.get("after") == []:
1993
+ return f"{at}: after lists the tasks it starts after; leave it out to give only a start."
1953
1994
  for k, v in args.items():
1954
1995
  kind = ACT_REF_BY_NOUN.get(noun) if k == "id" else ACT_REF_BY_ARG.get(k)
1955
1996
  if name == "task.link" or name == "task.unlink":
1956
1997
  kind = "task" if k in ("id", "after") else kind
1998
+ if name == "task.add" and k == "after":
1999
+ kind = "task"
1957
2000
  if name in ("task.add", "task.moveTo") and k == "bar":
1958
2001
  kind = "bar"
1959
2002
  if name == "initiative.waitFor" and k == "gate":
1960
2003
  kind = "gate|block"
1961
- if not kind or (k == "id" and (creates or name == "jira.epicNew")) or name.endswith(".remove") and k != "id" or name in ("link.remove", "initiative.unwait", "task.unlink", "milestone.doneWhen.remove", "workstream.removeMember", "outcome.unlinkJira") and k != "id":
2004
+ if not kind or (k == "id" and (creates or name in ("jira.epicNew", "workstream.reid"))) or name.endswith(".remove") and k != "id" or name in ("link.remove", "initiative.unwait", "task.unlink", "milestone.doneWhen.remove", "workstream.removeMember", "outcome.unlinkJira") and k != "id":
1962
2005
  continue
1963
2006
  vals = v if isinstance(v, list) else [v]
1964
2007
  for x in vals:
@@ -1966,6 +2009,16 @@ def check_action_op(o, at: str, ids: dict, made: dict) -> str:
1966
2009
  continue
1967
2010
  if not any(x in ids.get(kd, set()) or x in made.get(kd, set()) for kd in kind.split("|")):
1968
2011
  return f"{at}: {x[:60]} isn't in the plan (for {k})"
2012
+ if name == "cards.set":
2013
+ e = _cards_set_problem(args)
2014
+ if e:
2015
+ return f"{at}: {e}"
2016
+ if name == "jira.hide" and isinstance(args.get("why"), str) and len(args["why"].strip()) > HIDE_REASON_MAX:
2017
+ return f"{at}: a reason is up to {HIDE_REASON_MAX} characters."
2018
+ if name == "workstream.reid":
2019
+ e = _reid_problem(ids, args)
2020
+ if e:
2021
+ return f"{at}: {e}"
1969
2022
  if noun == "jira" and isinstance(args.get("keys"), list) and len({str(k).strip().upper() for k in args["keys"]}) > 100:
1970
2023
  return f"{at}: one change places up to 100 issues"
1971
2024
  if creates and isinstance(args.get("id"), str):
@@ -1973,12 +2026,70 @@ def check_action_op(o, at: str, ids: dict, made: dict) -> str:
1973
2026
  return ""
1974
2027
 
1975
2028
 
2029
+ # The Cards page's layout, as the page has it (harness144 checks these against the page): the fields a card can show,
2030
+ # in the order Settings lists them, and the densities. cards.set changes one part of the team's layout.
2031
+ CARD_LAYOUT_FIELDS = ["key", "summary", "status", "assignee", "points", "sprint", "epic", "parent", "labels", "priority", "due", "blocked", "initiative"]
2032
+ CARD_DENSITY = ["compact", "normal", "detailed"]
2033
+ HIDE_REASON_MAX = 200
2034
+
2035
+
2036
+ def _cards_set_problem(args) -> str:
2037
+ f, v = args.get("field"), args.get("value")
2038
+ if f not in ("fields", "density", "width"):
2039
+ return "field is one of fields, density, width."
2040
+ if f == "fields":
2041
+ if not isinstance(v, list) or not v:
2042
+ return "fields is a list of at least one field."
2043
+ bad = next((x for x in v if x not in CARD_LAYOUT_FIELDS), None)
2044
+ if bad is not None:
2045
+ return f"{str(bad)[:30]} isn't a card field. They are {', '.join(CARD_LAYOUT_FIELDS)}."
2046
+ if f == "density" and v not in CARD_DENSITY:
2047
+ return f"density is one of {', '.join(CARD_DENSITY)}."
2048
+ if f == "width" and v not in ("fixed", "points"):
2049
+ return 'width is "fixed" or "points".'
2050
+ return ""
2051
+
2052
+
2053
+ # A workstream id the page accepts: one in use, or a former id the plan keeps on its workstream (lane.was), which the page
2054
+ # reads as the id now. A new id (workstream.reid) follows the rules for a new workstream's and is never one of these.
2055
+ LANE_ID = re.compile(r"^(?!(?:constructor|prototype|toString|valueOf|hasOwnProperty)$)[A-Za-z0-9][A-Za-z0-9_.-]{0,59}$")
2056
+
2057
+
2058
+ def _lane_ids(plan) -> set:
2059
+ lanes = [l for l in plan.get("lanes") or [] if isinstance(l, dict)]
2060
+ return {l.get("id") for l in lanes} | {x for l in lanes for x in (l.get("was") if isinstance(l.get("was"), list) else []) if isinstance(x, str)}
2061
+
2062
+
2063
+ def _lane_of(plan) -> dict:
2064
+ """Each id and former id -> the workstream's id now."""
2065
+ out = {}
2066
+ for l in plan.get("lanes") or []:
2067
+ if isinstance(l, dict) and isinstance(l.get("id"), str):
2068
+ out[l["id"]] = l["id"]
2069
+ for x in l.get("was") if isinstance(l.get("was"), list) else []:
2070
+ if isinstance(x, str):
2071
+ out.setdefault(x, l["id"])
2072
+ return out
2073
+
2074
+
2075
+ def _reid_problem(ids: dict, args: dict) -> str:
2076
+ new, lane_of = args.get("id"), ids.get("laneOf") or {}
2077
+ if not isinstance(new, str) or not LANE_ID.match(new):
2078
+ return "a workstream id is 1 to 60 letters, digits, dots, dashes or underscores, and starts with a letter or digit"
2079
+ if new in lane_of:
2080
+ return "" if lane_of[new] == lane_of.get(args.get("lane")) else f"{new} is, or was, another workstream's id, so it isn't used again"
2081
+ if any(new in ids.get(k, set()) for k in ("bar", "task", "dv", "person", "ms", "gate", "risk", "dec")):
2082
+ return f"{new} is already used"
2083
+ return ""
2084
+
2085
+
1976
2086
  def _plan_ids(plan) -> dict:
1977
2087
  bars = [b for b in plan.get("bars") or [] if isinstance(b, dict)]
1978
2088
  pick = lambda key: {x.get("id") for x in plan.get(key) or [] if isinstance(x, dict)}
1979
2089
  return {"bar": {b.get("id") for b in bars}, "task": {t.get("id") for b in bars for t in b.get("tasks") or [] if isinstance(t, dict)},
1980
- "dv": pick("deliverables"), "lane": pick("lanes"), "person": pick("people"), "ms": pick("milestones"), "gate": pick("gates"),
1981
- "block": pick("blocks"), "risk": pick("risks"), "dec": pick("decisions"), "sprint": pick("sprints"), "skill": pick("skills"), "meeting": pick("meetings")}
2090
+ "dv": pick("deliverables"), "lane": _lane_ids(plan), "person": pick("people"), "ms": pick("milestones"), "gate": pick("gates"),
2091
+ "block": pick("blocks"), "risk": pick("risks"), "dec": pick("decisions"), "sprint": pick("sprints"), "skill": pick("skills"), "meeting": pick("meetings"),
2092
+ "laneOf": _lane_of(plan)}
1982
2093
 
1983
2094
 
1984
2095
  def _plan_start(plan) -> _dt.date:
@@ -2009,7 +2120,7 @@ def _item_err(fields, field, v, plan) -> str:
2009
2120
  ids = {p.get("id") for p in plan.get("people") or [] if isinstance(p, dict)}
2010
2121
  return "" if isinstance(v, list) and all(x in ids for x in v) else "owners must be ids of people in the plan"
2011
2122
  if t == "lane":
2012
- return "" if any(isinstance(l, dict) and l.get("id") == v for l in plan.get("lanes") or []) else f'workstream "{str(v)[:40]}" isn\'t in the plan'
2123
+ return "" if v in _lane_ids(plan) else f'workstream "{str(v)[:40]}" isn\'t in the plan'
2013
2124
  weeks = (plan.get("meta") or {}).get("weeks") or 0
2014
2125
  if t == "start":
2015
2126
  if not _iso_ok(v):
@@ -2063,7 +2174,7 @@ def _value_err(field, v, plan, status) -> str:
2063
2174
  if t == "release":
2064
2175
  return "" if any(str(r.get("id")) == str(v) for r in status.get("releases") or [] if isinstance(r, dict)) else f"release {str(v)[:30]} isn't in the last Jira sync"
2065
2176
  if t == "lane":
2066
- return "" if any(isinstance(l, dict) and l.get("id") == v for l in plan.get("lanes") or []) else f'workstream "{str(v)[:40]}" isn\'t in the plan'
2177
+ return "" if v in _lane_ids(plan) else f'workstream "{str(v)[:40]}" isn\'t in the plan'
2067
2178
  if t == "people":
2068
2179
  ids = {p.get("id") for p in plan.get("people") or [] if isinstance(p, dict)}
2069
2180
  return "" if isinstance(v, list) and all(x in ids for x in v) else "owners must be ids of people in the plan"