skilldrop-cli 1.8.0 → 1.10.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.
Files changed (35) hide show
  1. package/README.md +2 -1
  2. package/catalogue.json +10 -1
  3. package/model-routing.json +12 -0
  4. package/package.json +1 -1
  5. package/packs/startup-legal/pack.json +66 -0
  6. package/packs/startup-legal/skills/cap-table/SKILL.md +131 -0
  7. package/packs/startup-legal/skills/cap-table/evals/eval_queries.json +30 -0
  8. package/packs/startup-legal/skills/cap-table/evals/evals.json +46 -0
  9. package/packs/startup-legal/skills/cap-table/examples/seed-to-series-a.md +81 -0
  10. package/packs/startup-legal/skills/cap-table/manifest.json +34 -0
  11. package/packs/startup-legal/skills/cap-table/scripts/cap_table.py +344 -0
  12. package/packs/startup-legal/skills/cap-table/templates/cap.json +23 -0
  13. package/packs/startup-legal/skills/contract-check/SKILL.md +118 -0
  14. package/packs/startup-legal/skills/contract-check/evals/eval_queries.json +30 -0
  15. package/packs/startup-legal/skills/contract-check/evals/evals.json +46 -0
  16. package/packs/startup-legal/skills/contract-check/evals/files/1/saas-agreement.md +68 -0
  17. package/packs/startup-legal/skills/contract-check/evals/files/2/consulting-agreement.md +64 -0
  18. package/packs/startup-legal/skills/contract-check/evals/files/3/reseller-agreement.md +69 -0
  19. package/packs/startup-legal/skills/contract-check/examples/inputs/saas-agreement.md +68 -0
  20. package/packs/startup-legal/skills/contract-check/examples/saas-agreement.md +72 -0
  21. package/packs/startup-legal/skills/contract-check/manifest.json +34 -0
  22. package/packs/startup-legal/skills/contract-check/reference.md +109 -0
  23. package/packs/startup-legal/skills/contract-check/scripts/contract_check.py +413 -0
  24. package/packs/startup-legal/skills/lease-check/SKILL.md +119 -0
  25. package/packs/startup-legal/skills/lease-check/evals/eval_queries.json +30 -0
  26. package/packs/startup-legal/skills/lease-check/evals/evals.json +45 -0
  27. package/packs/startup-legal/skills/lease-check/evals/files/1/heads-of-terms.md +15 -0
  28. package/packs/startup-legal/skills/lease-check/evals/files/2/heads-of-terms.md +32 -0
  29. package/packs/startup-legal/skills/lease-check/evals/files/3/heads-of-agreement.md +29 -0
  30. package/packs/startup-legal/skills/lease-check/examples/first-office.md +75 -0
  31. package/packs/startup-legal/skills/lease-check/examples/inputs/heads-of-terms.md +15 -0
  32. package/packs/startup-legal/skills/lease-check/manifest.json +33 -0
  33. package/packs/startup-legal/skills/lease-check/reference.md +70 -0
  34. package/packs/startup-legal/skills/lease-check/scripts/lease_terms.py +276 -0
  35. package/packs/startup-legal/skills/lease-check/templates/terms.json +15 -0
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env python3
2
+ """Check an agreement for the mechanical faults that slip past a read-through. Stdlib only.
3
+
4
+ Reads a contract as Markdown, plain text or Word (.docx) and reports, each with where it is:
5
+ - blanks left in: [●], [•], [___], ____, [DATE], [insert …], TBD
6
+ - cross-references to a clause, section, schedule, exhibit, annex or appendix that doesn't exist
7
+ - clause numbering that skips or repeats
8
+ - a defined term defined twice, defined and never used, or used in lower case once defined
9
+ - a role or object used as if defined (Customer, Services, Confidential Information …) but never defined
10
+ - an amount or period in words that disagrees with its figure: "thirty (60) days", "ten thousand ($1,000)"
11
+ - a date that doesn't exist (31 June, 29 February in a non-leap year)
12
+ and lists every notice period, so deadlines can be worked out.
13
+
14
+ It checks the document's consistency, not whether its terms are fair or lawful.
15
+
16
+ Usage:
17
+ python3 contract_check.py agreement.docx [--json]
18
+ Exit codes: 0 no failures, 1 a failure, 2 the file can't be read.
19
+ """
20
+ import argparse
21
+ import json
22
+ import re
23
+ import sys
24
+ import zipfile
25
+ from xml.etree import ElementTree
26
+
27
+ W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
28
+
29
+
30
+ def read_text(path):
31
+ if path.lower().endswith(".docx"):
32
+ with zipfile.ZipFile(path) as z:
33
+ root = ElementTree.fromstring(z.read("word/document.xml"))
34
+ lines = []
35
+ for p in root.iter(W + "p"):
36
+ text = "".join(t.text or "" for t in p.iter(W + "t"))
37
+ lines.append(text)
38
+ return lines
39
+ with open(path, encoding="utf-8-sig") as f:
40
+ return f.read().splitlines()
41
+
42
+
43
+ # ---------- numbering and cross-references ----------
44
+
45
+ MULTI = re.compile(r"^\s*(?:#+\s*)?(?:\*\*)?\s*(?:(?:clause|section|article)\s+)?(\d+(?:\.\d+){1,3})\.?(?=\s|\*\*|$)", re.I)
46
+ SINGLE = re.compile(r"^\s*(#+\s*)?(\*\*)?\s*((?:clause|section|article)\s+)?(\d{1,3})\.?(?=\s|\*\*|$)(.*)$", re.I)
47
+ APPENDIX_HEAD = re.compile(r"^\s*(?:#+\s*)?(?:\*\*)?\s*(schedule|exhibit|annex|appendix|annexure)\s+([A-Z0-9]{1,3})\b", re.I)
48
+ REF = re.compile(r"\b(clauses?|sections?|articles?)\s+(\d+(?:\.\d+){0,3})((?:\s*(?:,|and|or|to|-|–)\s*\d+(?:\.\d+){0,3})*)", re.I)
49
+ APPENDIX_REF = re.compile(r"\b(schedule|exhibit|annex|appendix|annexure)\s+([A-Z0-9]{1,3})\b", re.I)
50
+
51
+
52
+ def clause_number(line):
53
+ """The clause number a line starts, or None. "7.2 …" anywhere counts; a bare "7." counts only
54
+ as a heading (Markdown #, bold, "Clause 7", or a short title line), so a numbered list inside
55
+ a clause isn't read as clauses."""
56
+ m = MULTI.match(line)
57
+ if m:
58
+ return m.group(1)
59
+ m = SINGLE.match(line)
60
+ if not m:
61
+ return None
62
+ hashes, bold, word, n, rest = m.groups()
63
+ rest = rest.strip().strip("*").strip()
64
+ if hashes or bold or word:
65
+ return n
66
+ if rest and len(rest) <= 60 and not rest.endswith((".", ";", ",")) and rest[:1].isupper():
67
+ return n
68
+ return None
69
+
70
+
71
+ def appendix_at(line):
72
+ a = APPENDIX_HEAD.match(line)
73
+ if a and len(line.strip()) < 120:
74
+ return (a.group(1).lower().replace("annexure", "annex"), a.group(2).upper())
75
+ return None
76
+
77
+
78
+ def scan(lines):
79
+ """Clause numbers in the main body, appendices present, and each numbered line with its scope."""
80
+ nums, apps, numbered = {}, {}, []
81
+ scope = "body"
82
+ for i, line in enumerate(lines, 1):
83
+ key = appendix_at(line)
84
+ if key:
85
+ apps.setdefault(key, i)
86
+ scope = key
87
+ continue
88
+ n = clause_number(line)
89
+ if n:
90
+ numbered.append((scope, n, i))
91
+ if scope == "body":
92
+ nums.setdefault(n, i)
93
+ return nums, apps, numbered
94
+
95
+
96
+ def numbering_faults(numbered):
97
+ out = []
98
+ seen = {}
99
+ last = {}
100
+ for scope, n, i in numbered:
101
+ if (scope, n) in seen:
102
+ out.append(("FAIL", i, f"clause {n} is numbered twice (also line {seen[(scope, n)]})"))
103
+ continue
104
+ seen[(scope, n)] = i
105
+ parts = [int(x) for x in n.split(".")]
106
+ parent = (scope,) + tuple(parts[:-1])
107
+ prev = last.get(parent)
108
+ if prev is not None and parts[-1] > prev + 1:
109
+ want = ".".join(map(str, parts[:-1] + [prev + 1]))
110
+ out.append(("WARN", i, f"clause numbering jumps to {n}; {want} is missing"))
111
+ last[parent] = parts[-1]
112
+ for k in [k for k in last if len(k) > len(parent) and k[:len(parent)] == parent and k[len(parent)] != parts[-1]]:
113
+ del last[k]
114
+ return out
115
+
116
+
117
+ def ref_faults(lines, nums, apps):
118
+ out = []
119
+ tops = {n.split(".")[0] for n in nums}
120
+ if not nums:
121
+ return [("WARN", 0, "no numbered clauses found, so cross-references were not checked")]
122
+ for i, line in enumerate(lines, 1):
123
+ body = MULTI.sub("", line, count=1) if MULTI.match(line) else line
124
+ for m in REF.finditer(body):
125
+ targets = [m.group(2)] + re.findall(r"\d+(?:\.\d+){0,3}", m.group(3) or "")
126
+ for t in targets:
127
+ if t in nums or (("." not in t) and t in tops):
128
+ continue
129
+ out.append(("FAIL", i, f"refers to {m.group(1).rstrip('s').lower()} {t}, which doesn't exist"))
130
+ for m in APPENDIX_REF.finditer(line):
131
+ key = (m.group(1).lower().replace("annexure", "annex"), m.group(2).upper())
132
+ if appendix_at(line) and m.start() < 12:
133
+ continue
134
+ if key not in apps and not re.match(r"^(this|the|each|any|a)$", m.group(2), re.I):
135
+ out.append(("FAIL", i, f"refers to {key[0].title()} {key[1]}, which isn't in the document"))
136
+ return dedupe(out)
137
+
138
+
139
+ # ---------- blanks ----------
140
+
141
+ BLANKS = [
142
+ (re.compile(r"\[\s*[●•]+\s*\]|[●•]{1,}"), "a [●] placeholder"),
143
+ (re.compile(r"\[\s*_+\s*\]|_{4,}"), "a blank line to fill in"),
144
+ (re.compile(r"\[(?:insert|enter|add|date|name|address|amount|company|party|tbc|tbd|xx+)[^\]]{0,40}\]", re.I), "a bracketed placeholder"),
145
+ (re.compile(r"\bTB[CD]\b"), "TBD/TBC"),
146
+ (re.compile(r"\bX{2,}\b"), "an XX placeholder"),
147
+ ]
148
+
149
+
150
+ def blank_faults(lines):
151
+ out = []
152
+ for i, line in enumerate(lines, 1):
153
+ for rx, what in BLANKS:
154
+ m = rx.search(line)
155
+ if m:
156
+ out.append(("FAIL", i, f"{what} is still in: “{snippet(line, m.start())}”"))
157
+ break
158
+ return out
159
+
160
+
161
+ # ---------- defined terms ----------
162
+
163
+ DEF_PATTERNS = [
164
+ re.compile(r"\(\s*(?:the\s+|each\s+a\s+|together\s+the\s+|hereinafter\s+(?:the\s+)?)?[\"“]([A-Z][A-Za-z0-9&'\- ]{1,60}?)[\"”]\s*\)"),
165
+ re.compile(r"[\"“]([A-Z][A-Za-z0-9&'\- ]{1,60}?)[\"”]\s+(?:means|shall mean|has the meaning|includes|refers to)\b"),
166
+ re.compile(r"\*\*[\"“]?([A-Z][A-Za-z0-9&'\- ]{1,60}?)[\"”]?\*\*\s+(?:means|shall mean|has the meaning)\b"),
167
+ ]
168
+
169
+ COMMON = ["Agreement", "Customer", "Client", "Company", "Supplier", "Vendor", "Provider", "Service Provider",
170
+ "Licensee", "Licensor", "Contractor", "Consultant", "Services", "Deliverables", "Software", "Fees",
171
+ "Effective Date", "Term", "Territory", "Confidential Information", "Intellectual Property",
172
+ "Intellectual Property Rights", "Affiliate", "Personal Data", "Products", "Order Form",
173
+ "Statement of Work", "Disclosing Party", "Receiving Party", "Purpose", "Business Day",
174
+ "Commencement Date", "Investor", "Founder", "Employee", "Platform", "Subscription Term",
175
+ "Documentation", "Customer Data", "Work Product", "Background IP", "Licensed Materials"]
176
+
177
+
178
+ def definitions(lines):
179
+ defs = {}
180
+ for i, line in enumerate(lines, 1):
181
+ for rx in DEF_PATTERNS:
182
+ for m in rx.finditer(line):
183
+ term = m.group(1).strip()
184
+ if len(term) < 2 or term.lower() in ("the", "a", "an"):
185
+ continue
186
+ defs.setdefault(term, []).append(i)
187
+ return defs
188
+
189
+
190
+ def term_faults(lines, defs):
191
+ out = []
192
+ text = "\n".join(lines)
193
+ for term, where in defs.items():
194
+ places = sorted(set(where))
195
+ if len(places) > 1:
196
+ out.append(("FAIL", places[1], f"“{term}” is defined more than once (lines {', '.join(map(str, places))})"))
197
+ uses = len(re.findall(r"(?<![A-Za-z])" + re.escape(term) + r"(?![A-Za-z])", text))
198
+ if uses <= len(where):
199
+ out.append(("WARN", places[0], f"“{term}” is defined but never used"))
200
+ lower = [n for n, l in enumerate(lines, 1) if n not in places and
201
+ re.search(r"(?<![A-Za-z])" + re.escape(term.lower()) + r"(?![A-Za-z])", l) and len(term.split()) > 1]
202
+ if lower:
203
+ out.append(("WARN", lower[0], f"“{term.lower()}” appears in lower case although “{term}” is a "
204
+ f"defined term (lines {', '.join(map(str, lower[:5]))})"))
205
+ defined_lower = {t.lower() for t in defs}
206
+ for term in COMMON:
207
+ if term.lower() in defined_lower:
208
+ continue
209
+ rx = re.compile(r"(?<=[a-z,;] )(?:the )?" + re.escape(term) + r"(?![A-Za-z])")
210
+ hits = [(n, len(rx.findall(l))) for n, l in enumerate(lines, 1) if rx.search(l)]
211
+ count = sum(c for _, c in hits)
212
+ if count >= 2:
213
+ out.append(("WARN", hits[0][0], f"“{term}” is used as a defined term (capitalised, {count} times) but "
214
+ "never defined"))
215
+ return out
216
+
217
+
218
+ # ---------- amounts in words and figures ----------
219
+
220
+ UNITS = {w: i for i, w in enumerate("zero one two three four five six seven eight nine ten eleven twelve thirteen "
221
+ "fourteen fifteen sixteen seventeen eighteen nineteen".split())}
222
+ TENS = {w: 10 * i for i, w in enumerate("_ _ twenty thirty forty fifty sixty seventy eighty ninety".split()) if w != "_"}
223
+ SCALES = {"hundred": 100, "thousand": 1000, "lakh": 100000, "lakhs": 100000, "million": 10 ** 6,
224
+ "crore": 10 ** 7, "crores": 10 ** 7, "billion": 10 ** 9}
225
+ WORD = r"(?:zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|" \
226
+ r"seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand|lakhs?|" \
227
+ r"million|crores?|billion|and|point)"
228
+ WORDS_FIG = re.compile(r"\b((?:" + WORD + r")(?:[\s-]+" + WORD + r")*)\b(?:\s+[A-Za-z]+){0,3}?\s*\(\s*([$€£₹]?\s?[\d,]+(?:\.\d+)?)\s*\)", re.I)
229
+
230
+
231
+ def words_to_number(words):
232
+ total, current = 0, 0
233
+ toks = [t for t in re.split(r"[\s-]+", words.lower()) if t and t != "and"]
234
+ if not toks or "point" in toks:
235
+ return None
236
+ for t in toks:
237
+ if t in UNITS:
238
+ current += UNITS[t]
239
+ elif t in TENS:
240
+ current += TENS[t]
241
+ elif t == "hundred":
242
+ current = (current or 1) * 100
243
+ elif t in SCALES:
244
+ total += (current or 1) * SCALES[t]
245
+ current = 0
246
+ else:
247
+ return None
248
+ return total + current
249
+
250
+
251
+ def amount_faults(lines):
252
+ out = []
253
+ for i, line in enumerate(lines, 1):
254
+ for m in WORDS_FIG.finditer(line):
255
+ words = m.group(1)
256
+ if words.strip().lower() in ("and", "point", "one"):
257
+ if words.strip().lower() != "one":
258
+ continue
259
+ val = words_to_number(words)
260
+ fig = float(re.sub(r"[^\d.]", "", m.group(2)) or "nan")
261
+ if val is None or fig != fig:
262
+ continue
263
+ if abs(val - fig) > 1e-9:
264
+ out.append(("FAIL", i, f"“{m.group(0).strip()}”: the words say {val:,} and the figure says {fig:,.0f}"))
265
+ return out
266
+
267
+
268
+ # ---------- dates and notice periods ----------
269
+
270
+ MONTHS = {m: i for i, m in enumerate(["january", "february", "march", "april", "may", "june", "july", "august",
271
+ "september", "october", "november", "december"], 1)}
272
+ DATE1 = re.compile(r"\b(\d{1,2})(?:st|nd|rd|th)?\s+(?:day\s+of\s+)?(" + "|".join(MONTHS) + r"),?\s+(\d{4})\b", re.I)
273
+ DATE2 = re.compile(r"\b(" + "|".join(MONTHS) + r")\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b", re.I)
274
+ NOTICE = re.compile(r"\(?(\d{1,3})\)?\s+(business\s+days?|calendar\s+days?|days?|weeks?|months?)[’']?\s*"
275
+ r"(?:(?:prior\s+)?(?:written\s+)?notice|(?:prior\s+to|before)\s+(?:the\s+)?(?:end|expiry|expiration|renewal))",
276
+ re.I)
277
+
278
+
279
+ def date_faults(lines):
280
+ import calendar
281
+ out = []
282
+ for i, line in enumerate(lines, 1):
283
+ for m in list(DATE1.finditer(line)) + list(DATE2.finditer(line)):
284
+ if m.re is DATE1:
285
+ d, mon, y = int(m.group(1)), MONTHS[m.group(2).lower()], int(m.group(3))
286
+ else:
287
+ mon, d, y = MONTHS[m.group(1).lower()], int(m.group(2)), int(m.group(3))
288
+ if not 1 <= d <= calendar.monthrange(y, mon)[1]:
289
+ out.append(("FAIL", i, f"“{m.group(0)}” is not a real date"))
290
+ return out
291
+
292
+
293
+ def notices(lines, term_end=None):
294
+ found = []
295
+ for i, line in enumerate(lines, 1):
296
+ for m in NOTICE.finditer(line):
297
+ n = {"line": i, "period": f"{m.group(1)} {m.group(2).lower()}", "text": snippet(line, m.start(), 90)}
298
+ before_end = re.search(r"before|prior to|renew|expir|end of", line[m.start():], re.I)
299
+ if term_end and before_end:
300
+ n["notice_by"] = notice_by(term_end, int(m.group(1)), m.group(2).lower()).isoformat()
301
+ found.append(n)
302
+ return found
303
+
304
+
305
+ def notice_by(end, n, unit):
306
+ """The last day notice can be given so that n units still remain before `end`."""
307
+ import calendar
308
+ import datetime as dt
309
+ if unit.startswith("business"):
310
+ d, left = end, n
311
+ while left:
312
+ d -= dt.timedelta(days=1)
313
+ if d.weekday() < 5:
314
+ left -= 1
315
+ return d
316
+ if unit.startswith("week"):
317
+ return end - dt.timedelta(weeks=n)
318
+ if unit.startswith("month"):
319
+ y, mth = divmod(end.year * 12 + end.month - 1 - n, 12)
320
+ return end.replace(year=y, month=mth + 1, day=min(end.day, calendar.monthrange(y, mth + 1)[1]))
321
+ return end - dt.timedelta(days=n)
322
+
323
+
324
+ # ---------- output ----------
325
+
326
+ def snippet(line, at, width=60):
327
+ start = max(0, at - 20)
328
+ s = line[start:start + width].strip()
329
+ return ("…" if start else "") + s + ("…" if start + width < len(line) else "")
330
+
331
+
332
+ def dedupe(items):
333
+ seen, out = set(), []
334
+ for it in items:
335
+ if it not in seen:
336
+ seen.add(it)
337
+ out.append(it)
338
+ return out
339
+
340
+
341
+ def check(lines, term_end=None):
342
+ nums, apps, numbered = scan(lines)
343
+ defs = definitions(lines)
344
+ findings = (blank_faults(lines) + ref_faults(lines, nums, apps) + numbering_faults(numbered) +
345
+ term_faults(lines, defs) + amount_faults(lines) + date_faults(lines))
346
+ findings = dedupe(findings)
347
+ findings.sort(key=lambda f: (f[0] != "FAIL", f[1]))
348
+ return {"findings": findings, "clauses": len(nums), "appendices": sorted(f"{k[0].title()} {k[1]}" for k in apps),
349
+ "defined_terms": sorted(defs), "notices": notices(lines, term_end)}
350
+
351
+
352
+ def clause_at(lines, n):
353
+ """The nearest numbered clause at or above line n, for a reader who has the document open."""
354
+ for i in range(min(n, len(lines)), 0, -1):
355
+ if appendix_at(lines[i - 1]):
356
+ return None
357
+ c = clause_number(lines[i - 1])
358
+ if c:
359
+ return c
360
+ return None
361
+
362
+
363
+ def report(path, lines, r):
364
+ L = [f"# Contract check: {path}", "",
365
+ f"{r['clauses']} numbered clauses, {len(r['defined_terms'])} defined terms, "
366
+ f"{len(r['appendices'])} schedules or exhibits ({', '.join(r['appendices']) or 'none'}).", ""]
367
+ if not any(f[0] == "FAIL" for f in r["findings"]):
368
+ L.append("PASS no blanks, broken cross-references, repeated clause numbers, doubly defined terms, "
369
+ "mismatched amounts or impossible dates.")
370
+ for level, line, msg in r["findings"]:
371
+ where = f"line {line}" if line else "document"
372
+ c = clause_at(lines, line) if line else None
373
+ if c:
374
+ where += f", clause {c}"
375
+ L.append(f"{level} [{where}] {msg}")
376
+ if r["notices"]:
377
+ L += ["", "Notice periods:"]
378
+ for n in r["notices"]:
379
+ c = clause_at(lines, n["line"])
380
+ by = f" — give notice by {n['notice_by']}" if n.get("notice_by") else ""
381
+ L.append(f"- {n['period']} (line {n['line']}{', clause ' + c if c else ''}){by}: {n['text']}")
382
+ return "\n".join(L) + "\n"
383
+
384
+
385
+ def main(argv=None):
386
+ ap = argparse.ArgumentParser(description="Check an agreement's mechanics.")
387
+ ap.add_argument("document")
388
+ ap.add_argument("--json", action="store_true")
389
+ ap.add_argument("--term-end", help="YYYY-MM-DD the current term ends, to date each notice deadline")
390
+ args = ap.parse_args(argv)
391
+ term_end = None
392
+ if args.term_end:
393
+ import datetime as dt
394
+ try:
395
+ term_end = dt.date.fromisoformat(args.term_end)
396
+ except ValueError:
397
+ print("FAIL --term-end must be a date as YYYY-MM-DD")
398
+ return 2
399
+ try:
400
+ lines = read_text(args.document)
401
+ except (OSError, UnicodeDecodeError, zipfile.BadZipFile, KeyError, ElementTree.ParseError) as e:
402
+ print(f"FAIL cannot read {args.document}: {e}")
403
+ return 2
404
+ r = check(lines, term_end)
405
+ if args.json:
406
+ print(json.dumps(r, indent=2, ensure_ascii=False))
407
+ else:
408
+ print(report(args.document, lines, r), end="")
409
+ return 1 if any(f[0] == "FAIL" for f in r["findings"]) else 0
410
+
411
+
412
+ if __name__ == "__main__":
413
+ sys.exit(main())
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: lease-check
3
+ description: Check a commercial lease before a startup signs it for its first office, studio or lab — a script turns the lease's terms into every date and sum the tenant must plan around (rent year by year through stepped rents, fixed and index-linked reviews with caps and collars, rent-free months, what the company is committed to pay up to each break and to the end, and each break, renewal and notice deadline as a date), then flags terms that can't all be true and deadlines already passed — and reads the lease for the clauses that hurt a small tenant. Use when a founder says "review this office lease", "what's our break clause notice date", "how much are we committing to on this lease", "check this lease before I sign", "when can we get out of this lease", or "what does the rent review do".
4
+ ---
5
+
6
+ # lease-check
7
+
8
+ A first office lease is often the biggest commitment a young company signs, and founders sign
9
+ it on the headline rent. What hurts is in the detail. The break clause needs six months'
10
+ notice and any unpaid sum voids it. The CPI review compounds three years at once. The rent-free
11
+ months are forfeited if the break is used. Repair obligations hand the tenant the roof. A
12
+ script turns the lease's terms into the numbers: every deadline as a date, the rent each year,
13
+ and what the company has committed to pay up to each exit. The agent reads the lease from the
14
+ tenant's side.
15
+
16
+ ## How to respond
17
+
18
+ **These rules come before the steps and outrank them:**
19
+
20
+ - **Lead with the commitment and the exits, in a founder's words.** ✅ *"Signing commits you
21
+ to £113,400 up to the break in January 2030. To use the break you must give notice by 1 July
22
+ 2029 and have paid everything, down to the last service-charge invoice, or the break fails
23
+ and you're in to 2031 for another £90,590."* Never mention a script, a JSON file, this
24
+ skill's name, a path inside its folder or a temporary path, and never call your work "the
25
+ check" or "a tool".
26
+ - **Every date and sum comes from the computed terms,** with the clause behind it.
27
+ - **Read for the tenant's side,** and give the wording to ask for on each problem, ready to
28
+ paste into an email to the agent or landlord.
29
+ - **Say what the terms can't price:** an open-market review, a service charge with no cap,
30
+ dilapidations at the end. Name each one, and suggest the ask that bounds it.
31
+ - **Name what is worth a lawyer's hour, and only that.** One or two lines: a personal
32
+ guarantee, full repairing terms on an old building, a break with conditions the landlord
33
+ won't soften, or a lease outside the protection of business-tenancy law where that applies.
34
+ Never refuse, never open with a disclaimer.
35
+ - **Working files stay out of sight.** The terms you extract go in a private temporary folder
36
+ (`mktemp -d`) and are deleted before replying. Never overwrite their lease.
37
+
38
+ 1. **Read the lease or heads of terms** and extract: start date, term in months, starting
39
+ rent, how it's paid, rent-free months, stepped rents, each rent review (month, type, any
40
+ cap, collar, upward-only), service charge and any cap, deposit, each break (month, notice,
41
+ who can break, conditions, any penalty), renewal option, notice to leave. Write down the
42
+ clause for each.
43
+
44
+ 2. **Write the terms** in the shape of [`templates/terms.json`](templates/terms.json). An
45
+ index-linked review over several years takes `years` so the cap and collar compound the way
46
+ the lease says; check the lease's wording, which differs on this. When the lease names a
47
+ break by its day ("the last day of the third year" is 28 February; "the third anniversary"
48
+ is 1 March), give it as the break's `date`: the notice date follows from it.
49
+
50
+ 3. **Run it** on today's date, so passed deadlines are flagged.
51
+
52
+ ```bash
53
+ work=$(mktemp -d 2>/dev/null || mktemp -d ./.work.XXXXXX) # a private folder for this run only
54
+ # Claude Code
55
+ python3 "${CLAUDE_SKILL_DIR}/scripts/lease_terms.py" "$work/terms.json" --as-of 2026-11-08
56
+ # Other IDEs (from the skill folder)
57
+ python3 scripts/lease_terms.py "$work/terms.json"
58
+ ```
59
+
60
+ Exit 0 means computed, 1 means a deadline has passed or the terms contradict each other (a
61
+ break after the end, a notice that runs back before the start, rent-free as long as the
62
+ term), and 2 means the input can't be read.
63
+
64
+ 4. **Read the lease for the tenant** against [`reference.md`](reference.md): break conditions,
65
+ repair and reinstatement, dilapidations, service charge, alienation (subletting, assignment,
66
+ sharing with a group company), use, alterations, guarantees and rent deposits, insurance,
67
+ and whether the rent-free survives a break. For each problem: clause, why it hurts, wording
68
+ to ask for. Mark severity: 🟥 don't sign as is, 🟧 push back, 🟨 worth asking.
69
+
70
+ 5. **Reply in this order:**
71
+ 1. **The commitment and the exits:** what signing commits to up to each break and to the
72
+ end, and the notice date for each break.
73
+ 2. **Fixes before signing,** most severe first, with wording to ask for.
74
+ 3. **Deadlines to diarise,** each as a date with its clause.
75
+ 4. **Rent by year,** and what can't be priced yet.
76
+ 5. **What to have a lawyer look at,** one or two lines.
77
+
78
+ **Non-interactive runs** (subagent, CI, headless): unsaid terms are left out and named as
79
+ missing, never filled with a typical value. With no start date, term or rent, emit `BLOCKED:
80
+ need the lease (or heads of terms) with its start date, length and rent`.
81
+
82
+ ## Useful references in this skill
83
+
84
+ - [`reference.md`](reference.md) — the lease clauses that hurt a small tenant, and what to ask for in each
85
+ - [`templates/terms.json`](templates/terms.json) — the input: a five-year lease with rent-free, a capped CPI review and a conditional break
86
+ - [`scripts/lease_terms.py`](scripts/lease_terms.py) — rent by year through reviews, committed cost to each break and the end, deadlines, contradictions
87
+ - [`examples/first-office.md`](examples/first-office.md) — worked example: heads of terms for a first office, and what the break really costs
88
+
89
+ ## Quality bar
90
+
91
+ - **The committed cost to each break and to the end is a sum,** rent and service charge, after rent-free.
92
+ - **Every deadline is a date with its clause,** and a passed one is said plainly.
93
+ - **Index-linked reviews compound the way the lease says,** within its cap and collar, and upward-only is applied.
94
+ - **What can't be priced is named,** with the ask that bounds it.
95
+ - **Every problem has the wording to ask for.**
96
+ - **The reply leads with the commitment and the exits,** with no mention of scripts or files.
97
+
98
+ ## When to use this skill
99
+
100
+ - ✅ Heads of terms or a draft lease for a first office, studio, lab or shop
101
+ - ✅ "When can we get out of this lease, and what does it cost to stay till then?"
102
+ - ✅ A rent review letter, or a break date coming up
103
+ - ✅ Comparing two buildings on what each really commits the company to
104
+
105
+ ## When NOT to use this skill
106
+
107
+ - ❌ A coworking membership with monthly terms: read its notice period, no schedule needed
108
+ - ❌ Residential tenancies
109
+ - ❌ A dispute with a landlord, or a dilapidations claim already made: get a surveyor or a lawyer
110
+ - ❌ Buying a property
111
+
112
+ ## Anti-patterns to avoid
113
+
114
+ - ❌ **Quoting the headline rent as the cost.** Rent-free, reviews and service charge move it; give the committed sum.
115
+ - ❌ **"The break is in year three."** The notice date is what matters, and it's six months earlier.
116
+ - ❌ **Treating a conditional break as certain.** One unpaid invoice can void it.
117
+ - ❌ **Compounding a three-year CPI review as one year,** or ignoring its cap.
118
+ - ❌ **Missing that rent-free is clawed back** if the break is used, when the lease says so.
119
+ - ❌ **"The calculation shows…"** The founder wants the sum and the date.
@@ -0,0 +1,30 @@
1
+ [
2
+ {
3
+ "query": "Review this office lease before we sign",
4
+ "should_trigger": true
5
+ },
6
+ {
7
+ "query": "What's the notice date for our lease break clause?",
8
+ "should_trigger": true
9
+ },
10
+ {
11
+ "query": "How much are we committing to on this lease?",
12
+ "should_trigger": true
13
+ },
14
+ {
15
+ "query": "When can we get out of our office lease and what does it cost to stay?",
16
+ "should_trigger": true
17
+ },
18
+ {
19
+ "query": "Review this SaaS agreement before I sign it",
20
+ "should_trigger": false
21
+ },
22
+ {
23
+ "query": "Find us coworking spaces in Manchester",
24
+ "should_trigger": false
25
+ },
26
+ {
27
+ "query": "Should we rent or buy a flat?",
28
+ "should_trigger": false
29
+ }
30
+ ]
@@ -0,0 +1,45 @@
1
+ {
2
+ "skill_name": "lease-check",
3
+ "evals": [
4
+ {
5
+ "id": 1,
6
+ "prompt": "We've been offered heads of terms for our first office (heads-of-terms.md). The agent wants an answer this week. What are we committing to, and when could we get out?",
7
+ "assertions": [
8
+ "Gives the committed cost to the break on 1 January 2030 as about £113,400 (rent after 3 months rent-free plus service charge) and to the end as about £203,990",
9
+ "Gives the break notice deadline as 1 July 2029",
10
+ "Warns that the break's conditions (all sums paid, vacant possession) can make it fail, and gives wording to narrow them",
11
+ "Flags the personal guarantee, the uncapped service charge and full repairing with no schedule of condition, with wording to ask for",
12
+ "Computes the year-3 review at the 4% cap compounded over three years (about £40,495) or explains the range",
13
+ "Does not mention a script or the skill's name, and does not invent lease terms not in the heads of terms"
14
+ ]
15
+ },
16
+ {
17
+ "id": 2,
18
+ "held_out": "2026-11-08",
19
+ "prompt": "check this lease before I sign - heads-of-terms.md. how much are we committing to and when's the break notice date?",
20
+ "assertions": [
21
+ "Notices that a 5-year term from 1 March 2027 ends on 29 February 2032 (2032 is a leap year), and flags the heads of terms' 'expiring on 1 March 2032' as inconsistent with the stated term",
22
+ "Rent year by year: year 1 GBP 12,000 (6 months rent-free, then 6 x 2,000), year 2 GBP 24,000, years 3, 4 and 5 GBP 26,400 each",
23
+ "Committed to the break (to 28 February 2030): rent 12,000 + 24,000 + 26,400 = GBP 62,400 plus service charge 3 x 3,600 = GBP 10,800, total GBP 73,200 (exclusive of VAT, service charge at the current budget), with the convention stated",
24
+ "Committed to the end of the term: rent 12,000 + 24,000 + 3 x 26,400 = GBP 115,200 plus service charge 5 x 3,600 = GBP 18,000, total GBP 133,200",
25
+ "Break notice must be served on or before 28 August 2029 (6 months before the 28 February 2030 break date), and the answer warns that the break fails if any sum is unpaid or vacant possession is not given",
26
+ "Flags at least two tenant-hostile terms: the director's personal guarantee for the whole term, the uncapped service charge, and full repairing with no schedule of condition",
27
+ "Does not invent a rent deposit, VAT rate, open-market rent review, or other terms not in the heads of terms"
28
+ ]
29
+ },
30
+ {
31
+ "id": 3,
32
+ "held_out": "2026-11-08",
33
+ "prompt": "We're Quokka Bio, a 9-person biotech, and this would be our first lab. Our landlord's agent sent heads-of-agreement.md and wants it signed off this week. Can you work out what we'd be committing to pay to the break and to the end of the term, give me every notice date we need in the calendar, and tell me which clauses I should push back on? Treat all money as ex GST, and use the outgoings estimate as flat for the whole term.",
34
+ "assertions": [
35
+ "Flags the conflict between '3 months rent-free from the commencement date' (rent would start 1 December 2027) and the stated rent commencement date of 1 November 2027 (only 2 months), says which it used and asks for it to be fixed",
36
+ "Gives the CPI reviews as a range using the collar and cap: from 1 September 2028 rent is A$97,920 (2%) to A$100,800 (5%); from 1 September 2029 A$99,878.40 (2% on 2%) to A$105,840 (5% on 5%); the minimum is what is committed",
37
+ "Committed to the 31 August 2029 break using the 3-month rent-free: rent 72,000 (9 months of year 1) + 97,920 = A$169,920 plus outgoings 2 x 14,400 = A$28,800, total A$198,720 at the 2% collar (A$201,600 at the 5% cap), plus a break fee of 3 months' rent of A$24,480 (A$25,200 at cap), so about A$223,200 to A$226,800 to exit at the break; accept figures recomputed consistently on the 2-month reading if the convention is stated",
38
+ "Committed to the end of the term (31 August 2030): rent 72,000 + 97,920 + 99,878.40 = A$269,798.40 plus outgoings 3 x 14,400 = A$43,200, total A$312,998.40 at the collar (A$321,840 at the cap)",
39
+ "Break notice must be given no later than 28 February 2029 (6 months before 31 August 2029; there is no 29 February in 2029); the option to renew can only be exercised between 28 February 2030 (accept 1 March 2030) and 31 May 2030",
40
+ "Flags as hostile to a small tenant at least: the break condition voided by any breach at notice or break date, the upward-only market review on renewal, the uncapped outgoings, and the make-good to base-building condition",
41
+ "Does not invent a CPI figure presented as fact, a guarantor, a fit-out contribution, or other terms not in the heads of agreement"
42
+ ]
43
+ }
44
+ ]
45
+ }
@@ -0,0 +1,15 @@
1
+ # Heads of terms — Unit 4, Foundry Yard, Example Town
2
+
3
+ - Landlord: Foundry Yard Estates Ltd
4
+ - Tenant: Brightloop Labs Ltd
5
+ - Premises: Unit 4, first floor, 2,100 sq ft
6
+ - Term: 5 years from 1 January 2027
7
+ - Rent: £36,000 a year exclusive, payable quarterly in advance
8
+ - Rent-free: first 3 months
9
+ - Rent review: at the end of year 3, to CPI over the preceding three years, compounded, with a cap of 4% a year and a collar of 1% a year; upward only
10
+ - Service charge: estimated £4,800 a year, no cap
11
+ - Deposit: 6 months' rent
12
+ - Tenant break: end of year 3, on 6 months' prior written notice, conditional on all sums due under the lease having been paid and vacant possession being given
13
+ - Repair: full repairing; no schedule of condition
14
+ - Alienation: no subletting or sharing
15
+ - Personal guarantee: from one director for the first 3 years
@@ -0,0 +1,32 @@
1
+ # Heads of Terms - Subject to Contract
2
+
3
+ **Property:** Unit 4, Gorse Mill, Canal Street, Halbrook HB3 7QT (ground floor studio, approx. 1,850 sq ft)
4
+
5
+ **Landlord:** Gorse Mill Estates Limited
6
+
7
+ **Tenant:** Fernlight Studio Ltd
8
+
9
+ **Guarantor:** Imogen Marsh (director), personal guarantee of all tenant obligations for the whole term
10
+
11
+ **Term:** 5 years from and including 1 March 2027, expiring on 1 March 2032
12
+
13
+ **Rent:**
14
+ - Years 1 and 2 (1 March 2027 to 28 February 2029): GBP 24,000 per annum exclusive
15
+ - Years 3 to 5 (1 March 2029 to end of term): fixed increase to GBP 26,400 per annum exclusive
16
+ - Payable quarterly in advance on the usual quarter days
17
+
18
+ **Rent-free:** The first 6 months of the term are rent-free. Service charge is payable from the start of the term and is not covered by the rent-free period.
19
+
20
+ **Service charge:** Tenant to pay a fair proportion of the landlord's costs of the estate. Current budget GBP 3,600 per annum. No cap.
21
+
22
+ **Repair:** Full repairing and insuring. Tenant to keep the premises in good and substantial repair and condition and yield up in that condition. No schedule of condition.
23
+
24
+ **Tenant break:** Tenant may terminate the lease at the end of the third year of the term (28 February 2030) by giving the landlord not less than 6 months' prior written notice. The break is conditional on: (i) all rent and other sums due under the lease having been paid in full, and (ii) the tenant giving vacant possession on the break date.
25
+
26
+ **Use:** Design studio and ancillary offices (Class E).
27
+
28
+ **Alienation:** No assignment or subletting of part. Assignment of the whole with landlord's consent, not to be unreasonably withheld, subject to an authorised guarantee agreement.
29
+
30
+ **Security of tenure:** The lease is to be contracted out of the security of tenure provisions of the Landlord and Tenant Act 1954.
31
+
32
+ **Costs:** Each party to bear its own legal costs.