skilldrop-cli 1.9.0 → 1.11.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/README.md +3 -2
- package/catalogue.json +11 -2
- package/model-routing.json +12 -0
- package/package.json +1 -1
- package/packs/educator/pack.json +25 -0
- package/packs/educator/skills/exam-paper-check/SKILL.md +125 -0
- package/packs/educator/skills/exam-paper-check/evals/eval_queries.json +30 -0
- package/packs/educator/skills/exam-paper-check/evals/evals.json +73 -0
- package/packs/educator/skills/exam-paper-check/evals/files/1/year9-science-scheme.md +16 -0
- package/packs/educator/skills/exam-paper-check/evals/files/1/year9-science.md +58 -0
- package/packs/educator/skills/exam-paper-check/evals/files/2/mock-paper1-ms.md +32 -0
- package/packs/educator/skills/exam-paper-check/evals/files/2/mock-paper1.md +60 -0
- package/packs/educator/skills/exam-paper-check/evals/files/3/hist1042-quiz-markscheme.md +37 -0
- package/packs/educator/skills/exam-paper-check/evals/files/3/hist1042-quiz.md +50 -0
- package/packs/educator/skills/exam-paper-check/evals/files/4/chem-mock-paper.md +51 -0
- package/packs/educator/skills/exam-paper-check/evals/files/4/chem-mock-scheme.md +32 -0
- package/packs/educator/skills/exam-paper-check/evals/files/5/econ-p2-paper.md +57 -0
- package/packs/educator/skills/exam-paper-check/evals/files/5/econ-p2-scheme.md +34 -0
- package/packs/educator/skills/exam-paper-check/examples/inputs/spec.json +3 -0
- package/packs/educator/skills/exam-paper-check/examples/inputs/year9-science-scheme.md +16 -0
- package/packs/educator/skills/exam-paper-check/examples/inputs/year9-science.md +58 -0
- package/packs/educator/skills/exam-paper-check/examples/year9-forces.md +68 -0
- package/packs/educator/skills/exam-paper-check/manifest.json +33 -0
- package/packs/educator/skills/exam-paper-check/scripts/exam_check.py +328 -0
- package/packs/educator/skills/exam-paper-check/templates/spec.json +11 -0
- package/packs/startup-legal/pack.json +20 -4
- package/packs/startup-legal/skills/lease-check/SKILL.md +119 -0
- package/packs/startup-legal/skills/lease-check/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/lease-check/evals/evals.json +45 -0
- package/packs/startup-legal/skills/lease-check/evals/files/1/heads-of-terms.md +15 -0
- package/packs/startup-legal/skills/lease-check/evals/files/2/heads-of-terms.md +32 -0
- package/packs/startup-legal/skills/lease-check/evals/files/3/heads-of-agreement.md +29 -0
- package/packs/startup-legal/skills/lease-check/examples/first-office.md +75 -0
- package/packs/startup-legal/skills/lease-check/examples/inputs/heads-of-terms.md +15 -0
- package/packs/startup-legal/skills/lease-check/manifest.json +33 -0
- package/packs/startup-legal/skills/lease-check/reference.md +70 -0
- package/packs/startup-legal/skills/lease-check/scripts/lease_terms.py +276 -0
- package/packs/startup-legal/skills/lease-check/templates/terms.json +15 -0
- package/packs/startup-legal/skills/vesting-schedule/SKILL.md +122 -0
- package/packs/startup-legal/skills/vesting-schedule/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/vesting-schedule/evals/evals.json +70 -0
- package/packs/startup-legal/skills/vesting-schedule/examples/cofounder-leaving.md +66 -0
- package/packs/startup-legal/skills/vesting-schedule/examples/inputs/vesting.json +8 -0
- package/packs/startup-legal/skills/vesting-schedule/manifest.json +33 -0
- package/packs/startup-legal/skills/vesting-schedule/scripts/vesting.py +280 -0
- package/packs/startup-legal/skills/vesting-schedule/templates/vesting.json +12 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check an exam paper and its mark scheme for the faults students and moderators find. Stdlib only.
|
|
3
|
+
|
|
4
|
+
Reads the paper (Markdown, plain text or .docx) and, optionally, its mark scheme and a spec
|
|
5
|
+
(duration, objectives and their target weightings, which question parts assess which objective).
|
|
6
|
+
Reports, each with the question it is in:
|
|
7
|
+
- part marks that don't add up to a question's stated total, and questions that don't add up
|
|
8
|
+
to the paper's stated total (an "answer either 5 or 6" choice counts once)
|
|
9
|
+
- question numbers that skip or repeat, and parts that skip ((a), (c)) or repeat
|
|
10
|
+
- a part with no marks shown
|
|
11
|
+
- a part the mark scheme doesn't cover, a scheme entry for a part that isn't in the paper,
|
|
12
|
+
and a scheme whose marks differ from the paper's
|
|
13
|
+
- the time per mark, and an objective that is never assessed or is far off its weighting
|
|
14
|
+
It checks the paper's arithmetic and structure, not whether its questions are good.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 exam_check.py paper.md [--scheme scheme.md] [--spec spec.json] [--json]
|
|
18
|
+
Exit codes: 0 no failures, 1 a failure, 2 a 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
|
+
ROMAN = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii"]
|
|
29
|
+
|
|
30
|
+
QUESTION = re.compile(r"^\s*(?:#+\s*)?(?:\*\*)?\s*(?:question|q)\s*\.?\s*(\d{1,3})\b[.):]?(?:\*\*)?\s*(.*)$", re.I)
|
|
31
|
+
BARE_Q = re.compile(r"^\s*(?:\*\*(\d{1,3})[.)]?\*\*|(\d{1,3})[.)])\s+(.*)$")
|
|
32
|
+
PLAIN_Q = re.compile(r"^\s*(\d{1,3})\s+([A-Za-z].*)$")
|
|
33
|
+
COMBINED = re.compile(r"^\s*(?:\*\*)?(\d{1,3})\s*\(?([a-z])\)?(?:\s*\(?((?:x|ix|iv|v?i{0,3}))\))?(?:\*\*)?[\s.:)]+(.*)$", re.I)
|
|
34
|
+
PART = re.compile(r"^\s*(?:\*\*)?\(?([a-z])\)(?:\*\*)?\s*(.*)$")
|
|
35
|
+
SUBPART = re.compile(r"^\s*(?:\*\*)?\(((?:x|ix|iv|v?i{1,3}|v))\)(?:\*\*)?\s*(.*)$", re.I)
|
|
36
|
+
MARK = re.compile(r"[\[(]\s*(\d{1,3})\s*(?:marks?|m)?\s*[\])]\s*\**\s*$", re.I)
|
|
37
|
+
Q_TOTAL = re.compile(r"(?:total\s+for\s+question\s+(\d{1,3})\s*(?:is|=|:)?\s*(\d{1,3})\s*marks?"
|
|
38
|
+
r"|question\s+(\d{1,3})\s+total\s*(?:is|=|:)?\s*(\d{1,3})"
|
|
39
|
+
r"|\[\s*total\s*(?:for\s+question\s+(\d{1,3}))?\s*[:=]?\s*(\d{1,3})\s*(?:marks?)?\s*\])", re.I)
|
|
40
|
+
PAPER_TOTAL = re.compile(r"(?:total\s+(?:for\s+(?:this\s+)?paper|marks?\s+(?:for\s+(?:this\s+)?paper|available)|marks?)"
|
|
41
|
+
r"\s*(?:is|=|:)?\s*(\d{1,4})|(?:this\s+paper\s+is\s+worth|maximum\s+mark\s*(?:is|:)?)\s*(\d{1,4})"
|
|
42
|
+
r"|(\d{1,4})\s+marks?\s+in\s+total|total\s+of\s+(\d{1,4})\s+marks?)", re.I)
|
|
43
|
+
DURATION = re.compile(r"(?:time(?:\s+allowed)?|duration)\s*[:=]?\s*(?:(\d{1,2})\s*h(?:ours?|rs?)?\s*)?(?:(\d{1,3})\s*min(?:utes?|s)?)?", re.I)
|
|
44
|
+
EITHER = re.compile(r"answer\s+either\s+question\s+(\d{1,3})\s+or\s+(?:question\s+)?(\d{1,3})", re.I)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_text(path):
|
|
48
|
+
if path.lower().endswith(".docx"):
|
|
49
|
+
with zipfile.ZipFile(path) as z:
|
|
50
|
+
root = ElementTree.fromstring(z.read("word/document.xml"))
|
|
51
|
+
return ["".join(t.text or "" for t in p.iter(W + "t")) for p in root.iter(W + "p")]
|
|
52
|
+
with open(path, encoding="utf-8-sig") as f:
|
|
53
|
+
return f.read().splitlines()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse(lines):
|
|
57
|
+
"""Questions with their parts and marks, in document order, plus the stated totals."""
|
|
58
|
+
qs = []
|
|
59
|
+
q = part = None
|
|
60
|
+
stated, stated_line, duration, choices = None, None, None, []
|
|
61
|
+
|
|
62
|
+
def new_question(n, line):
|
|
63
|
+
nonlocal q, part
|
|
64
|
+
q = {"n": n, "line": line, "parts": [], "stated": None, "marks": None}
|
|
65
|
+
qs.append(q)
|
|
66
|
+
part = None
|
|
67
|
+
|
|
68
|
+
def new_part(label, line):
|
|
69
|
+
nonlocal part
|
|
70
|
+
part = {"label": label, "line": line, "marks": None}
|
|
71
|
+
q["parts"].append(part)
|
|
72
|
+
|
|
73
|
+
for i, raw in enumerate(lines, 1):
|
|
74
|
+
line = raw.strip()
|
|
75
|
+
if not line:
|
|
76
|
+
continue
|
|
77
|
+
totals = list(Q_TOTAL.finditer(line))
|
|
78
|
+
if totals:
|
|
79
|
+
for m in totals:
|
|
80
|
+
qn = m.group(1) or m.group(3) or m.group(5)
|
|
81
|
+
val = int(m.group(2) or m.group(4) or m.group(6))
|
|
82
|
+
target = next((x for x in reversed(qs) if str(x["n"]) == qn), None) if qn else q
|
|
83
|
+
if target is not None:
|
|
84
|
+
target["stated"] = val
|
|
85
|
+
continue
|
|
86
|
+
pm = PAPER_TOTAL.search(line)
|
|
87
|
+
if pm and stated is None and not re.search(r"question\s+\d", line, re.I):
|
|
88
|
+
stated, stated_line = int(next(g for g in pm.groups() if g)), i
|
|
89
|
+
continue
|
|
90
|
+
dm = DURATION.search(line)
|
|
91
|
+
if dm and duration is None and (dm.group(1) or dm.group(2)):
|
|
92
|
+
duration = int(dm.group(1) or 0) * 60 + int(dm.group(2) or 0)
|
|
93
|
+
choices += [(int(e.group(1)), int(e.group(2))) for e in EITHER.finditer(line)]
|
|
94
|
+
|
|
95
|
+
rest, labelled = line, False
|
|
96
|
+
m, c, b = QUESTION.match(line), COMBINED.match(line), BARE_Q.match(line)
|
|
97
|
+
if not b:
|
|
98
|
+
pl = PLAIN_Q.match(line) # "4 Level-marked…" in a scheme: only as the next question number
|
|
99
|
+
if pl and (q is None and pl.group(1) == "1" or q is not None and int(pl.group(1)) == q["n"] + 1):
|
|
100
|
+
b = re.match(r"^\s*()(\d{1,3})\s+(.*)$", line)
|
|
101
|
+
sub, pt = SUBPART.match(line), PART.match(line)
|
|
102
|
+
if m:
|
|
103
|
+
new_question(int(m.group(1)), i)
|
|
104
|
+
rest, labelled = m.group(2), True
|
|
105
|
+
elif c and c.group(2).lower() <= "j":
|
|
106
|
+
n = int(c.group(1))
|
|
107
|
+
if q is None or q["n"] != n:
|
|
108
|
+
new_question(n, i)
|
|
109
|
+
new_part(c.group(2).lower() + (f"({c.group(3).lower()})" if c.group(3) else ""), i)
|
|
110
|
+
rest, labelled = c.group(4), True
|
|
111
|
+
elif b and (q is None or b.group(1) or int(b.group(2)) == q["n"] + 1): # a bold number is always a question
|
|
112
|
+
new_question(int(b.group(1) or b.group(2)), i)
|
|
113
|
+
rest, labelled = b.group(3), True
|
|
114
|
+
elif q is not None and sub and part is not None and not (
|
|
115
|
+
sub.group(1).lower() == "i" and part["label"] == "h"): # (i) after (h) is the next part
|
|
116
|
+
new_part(f"{part['label'].split('(')[0]}({sub.group(1).lower()})", i)
|
|
117
|
+
rest, labelled = sub.group(2), True
|
|
118
|
+
elif q is not None and pt:
|
|
119
|
+
new_part(pt.group(1).lower(), i)
|
|
120
|
+
rest, labelled = pt.group(2), True
|
|
121
|
+
inner = SUBPART.match(rest) # "(a) (i) Describe…" opens the part and its first subpart
|
|
122
|
+
if inner:
|
|
123
|
+
new_part(f"{part['label']}({inner.group(1).lower()})", i)
|
|
124
|
+
rest = inner.group(2)
|
|
125
|
+
mk = MARK.search(rest)
|
|
126
|
+
if mk and q is not None:
|
|
127
|
+
if part is not None:
|
|
128
|
+
if part["marks"] is None:
|
|
129
|
+
part["marks"] = int(mk.group(1))
|
|
130
|
+
elif q["marks"] is None:
|
|
131
|
+
q["marks"] = int(mk.group(1))
|
|
132
|
+
return {"questions": qs, "stated_total": stated, "stated_line": stated_line,
|
|
133
|
+
"duration": duration, "choices": choices}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def q_marks(q):
|
|
137
|
+
leaves = leaf_parts(q)
|
|
138
|
+
if leaves:
|
|
139
|
+
return sum(p["marks"] or 0 for p in leaves)
|
|
140
|
+
return q["marks"] or 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def leaf_parts(q):
|
|
144
|
+
"""Parts that carry marks themselves: (a)(i) and (a)(ii), not (a) when it has subparts."""
|
|
145
|
+
labels = [p["label"] for p in q["parts"]]
|
|
146
|
+
return [p for p in q["parts"] if not any(o.startswith(p["label"] + "(") for o in labels)]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check(paper, scheme=None, spec=None):
|
|
150
|
+
fails, warns = [], []
|
|
151
|
+
qs = paper["questions"]
|
|
152
|
+
if not qs:
|
|
153
|
+
return {"fails": ["no numbered questions found"], "warns": [], "rows": [], "total": 0, "stated_total": None,
|
|
154
|
+
"duration": None, "pace": None, "coverage": {}}
|
|
155
|
+
seen = {}
|
|
156
|
+
for q in qs:
|
|
157
|
+
if q["n"] in seen:
|
|
158
|
+
fails.append(f"Question {q['n']} is numbered twice (lines {seen[q['n']]} and {q['line']})")
|
|
159
|
+
seen.setdefault(q["n"], q["line"])
|
|
160
|
+
nums = sorted(seen)
|
|
161
|
+
for a, b in zip(nums, nums[1:]):
|
|
162
|
+
if b != a + 1:
|
|
163
|
+
warns.append(f"question numbers jump from {a} to {b}")
|
|
164
|
+
if nums[0] != 1:
|
|
165
|
+
warns.append(f"the first question is numbered {nums[0]}")
|
|
166
|
+
rows = []
|
|
167
|
+
for q in qs:
|
|
168
|
+
letters, last_roman = [], {}
|
|
169
|
+
for p in q["parts"]:
|
|
170
|
+
base, _, sub = p["label"].partition("(")
|
|
171
|
+
if sub:
|
|
172
|
+
r = sub.rstrip(")")
|
|
173
|
+
prev = last_roman.get(base)
|
|
174
|
+
want = ROMAN[ROMAN.index(prev) + 1] if prev in ROMAN and ROMAN.index(prev) + 1 < len(ROMAN) else "i"
|
|
175
|
+
if r != want:
|
|
176
|
+
warns.append(f"Question {q['n']}({base}): part ({r}) follows ({prev or '—'}); expected ({want})")
|
|
177
|
+
last_roman[base] = r
|
|
178
|
+
else:
|
|
179
|
+
if base in letters:
|
|
180
|
+
fails.append(f"Question {q['n']}: part ({base}) appears twice")
|
|
181
|
+
elif letters and ord(base) != ord(letters[-1]) + 1:
|
|
182
|
+
warns.append(f"Question {q['n']}: part ({base}) follows ({letters[-1]}); "
|
|
183
|
+
f"({chr(ord(letters[-1]) + 1)}) is missing")
|
|
184
|
+
elif not letters and base != "a":
|
|
185
|
+
warns.append(f"Question {q['n']}: the first part is ({base})")
|
|
186
|
+
letters.append(base)
|
|
187
|
+
for p in leaf_parts(q):
|
|
188
|
+
if p["marks"] is None:
|
|
189
|
+
warns.append(f"Question {q['n']}({p['label']}) shows no marks")
|
|
190
|
+
if not q["parts"] and q["marks"] is None and q["stated"] is None:
|
|
191
|
+
warns.append(f"Question {q['n']} shows no marks")
|
|
192
|
+
total = q_marks(q) if (q["parts"] or q["marks"] is not None) else (q["stated"] or 0)
|
|
193
|
+
if q["stated"] is not None and (q["parts"] or q["marks"] is not None) and total != q["stated"]:
|
|
194
|
+
fails.append(f"Question {q['n']}: the parts add up to {total} but the question total says {q['stated']}")
|
|
195
|
+
rows.append({"question": q["n"], "marks": total, "stated": q["stated"],
|
|
196
|
+
"parts": {p["label"]: p["marks"] for p in leaf_parts(q)}})
|
|
197
|
+
by_n = {r["question"]: r["marks"] for r in rows}
|
|
198
|
+
total = sum(by_n.values())
|
|
199
|
+
for a, b in paper["choices"]:
|
|
200
|
+
if a in by_n and b in by_n:
|
|
201
|
+
total -= min(by_n[a], by_n[b])
|
|
202
|
+
if by_n[a] != by_n[b]:
|
|
203
|
+
fails.append(f"Questions {a} and {b} are a choice but are worth {by_n[a]} and {by_n[b]} marks")
|
|
204
|
+
stated = paper["stated_total"] if paper["stated_total"] is not None else (spec or {}).get("total")
|
|
205
|
+
if stated is not None and total != stated:
|
|
206
|
+
fails.append(f"the questions add up to {total} marks but the paper says {stated}")
|
|
207
|
+
duration = paper["duration"] or (spec or {}).get("duration_minutes")
|
|
208
|
+
pace = duration / total if duration and total else None
|
|
209
|
+
if scheme is not None:
|
|
210
|
+
smap = {}
|
|
211
|
+
for q in scheme["questions"]:
|
|
212
|
+
leaves = leaf_parts(q)
|
|
213
|
+
if leaves:
|
|
214
|
+
for p in leaves:
|
|
215
|
+
smap[f"{q['n']}{p['label']}"] = p["marks"]
|
|
216
|
+
else:
|
|
217
|
+
smap[str(q["n"])] = q["marks"]
|
|
218
|
+
pmap = {}
|
|
219
|
+
for q in qs:
|
|
220
|
+
leaves = leaf_parts(q)
|
|
221
|
+
if leaves:
|
|
222
|
+
for p in leaves:
|
|
223
|
+
pmap[f"{q['n']}{p['label']}"] = p["marks"]
|
|
224
|
+
else:
|
|
225
|
+
pmap[str(q["n"])] = q["marks"] if q["marks"] is not None else q["stated"]
|
|
226
|
+
for k, v in pmap.items():
|
|
227
|
+
if k not in smap:
|
|
228
|
+
fails.append(f"the mark scheme has nothing for {pretty(k)}")
|
|
229
|
+
elif smap[k] is not None and v is not None and smap[k] != v:
|
|
230
|
+
fails.append(f"{pretty(k)} is worth {v} on the paper but {smap[k]} in the mark scheme")
|
|
231
|
+
for k in smap:
|
|
232
|
+
if k not in pmap:
|
|
233
|
+
fails.append(f"the mark scheme covers {pretty(k)}, which isn't on the paper")
|
|
234
|
+
if scheme.get("stated_total") is not None:
|
|
235
|
+
s_by_q = {}
|
|
236
|
+
for k, v in smap.items():
|
|
237
|
+
n = int(re.match(r"\d+", k).group(0))
|
|
238
|
+
s_by_q[n] = s_by_q.get(n, 0) + (v or 0)
|
|
239
|
+
s_total = sum(s_by_q.values())
|
|
240
|
+
for a, b in paper["choices"]:
|
|
241
|
+
if a in s_by_q and b in s_by_q:
|
|
242
|
+
s_total -= min(s_by_q[a], s_by_q[b])
|
|
243
|
+
if s_total != scheme["stated_total"]:
|
|
244
|
+
fails.append(f"the mark scheme says it is out of {scheme['stated_total']} but its own marks add up to {s_total}")
|
|
245
|
+
coverage = {}
|
|
246
|
+
if spec and spec.get("assesses"):
|
|
247
|
+
targets = spec.get("objectives", {})
|
|
248
|
+
pmarks = {}
|
|
249
|
+
for q in qs:
|
|
250
|
+
leaves = leaf_parts(q)
|
|
251
|
+
for p in leaves or [{"label": "", "marks": q_marks(q)}]:
|
|
252
|
+
pmarks[f"{q['n']}{p['label']}"] = p["marks"] or 0
|
|
253
|
+
dropped = {str(b) for _, b in paper["choices"]} # a choice counts once
|
|
254
|
+
for key, objs in spec["assesses"].items():
|
|
255
|
+
k = key.replace(" ", "").lower()
|
|
256
|
+
if re.match(r"\d+", k).group(0) in dropped:
|
|
257
|
+
continue
|
|
258
|
+
share = pmarks.get(k)
|
|
259
|
+
if share is None:
|
|
260
|
+
matches = {pk: v for pk, v in pmarks.items() if pk.startswith(k)}
|
|
261
|
+
share = sum(matches.values()) if matches else None
|
|
262
|
+
if share is None:
|
|
263
|
+
warns.append(f"the spec maps {key} to objectives but there is no {key} on the paper")
|
|
264
|
+
continue
|
|
265
|
+
objs = [objs] if isinstance(objs, str) else objs
|
|
266
|
+
for o in objs:
|
|
267
|
+
coverage[o] = coverage.get(o, 0) + share / len(objs)
|
|
268
|
+
for o, target in targets.items():
|
|
269
|
+
got = coverage.get(o, 0)
|
|
270
|
+
if got == 0:
|
|
271
|
+
fails.append(f"objective {o} is never assessed")
|
|
272
|
+
elif target and total and abs(got / total * 100 - target) > max(5, target * 0.25):
|
|
273
|
+
warns.append(f"objective {o} has {got / total * 100:.0f} % of the marks against a target of {target:g} %")
|
|
274
|
+
return {"fails": fails, "warns": warns, "rows": rows, "total": total, "stated_total": stated,
|
|
275
|
+
"duration": duration, "pace": pace, "coverage": coverage}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def pretty(key):
|
|
279
|
+
m = re.match(r"(\d+)([a-z]?)(\(.*\))?", key)
|
|
280
|
+
n, a, r = m.groups()
|
|
281
|
+
return f"Question {n}" + (f"({a})" if a else "") + (r or "")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def report(r):
|
|
285
|
+
L = ["# Exam paper check", "", "| Question | Marks | Stated total | Parts |", "|---|---:|---:|---|"]
|
|
286
|
+
for row in r["rows"]:
|
|
287
|
+
parts = ", ".join(f"({k.replace('(', ')(').rstrip(')')}) {v if v is not None else '?'}" for k, v in row["parts"].items())
|
|
288
|
+
L.append(f"| {row['question']} | {row['marks']} | {row['stated'] if row['stated'] is not None else ''} | {parts} |")
|
|
289
|
+
L.append(f"| **Paper** | **{r['total']}** | {r['stated_total'] if r['stated_total'] is not None else ''} | |")
|
|
290
|
+
if r["pace"]:
|
|
291
|
+
L += ["", f"Time: {r['duration']} minutes for {r['total']} marks, {r['pace']:.2f} minutes a mark."]
|
|
292
|
+
if r["coverage"]:
|
|
293
|
+
L += ["", "| Objective | Marks | Share |", "|---|---:|---:|"]
|
|
294
|
+
for o, v in sorted(r["coverage"].items()):
|
|
295
|
+
L.append(f"| {o} | {v:g} | {v / r['total'] * 100:.0f} % |")
|
|
296
|
+
L.append("")
|
|
297
|
+
if not r["fails"]:
|
|
298
|
+
L.append("PASS every total adds up, numbering runs in order, and the scheme matches the paper.")
|
|
299
|
+
L += [f"FAIL {f}" for f in r["fails"]] + [f"WARN {w}" for w in r["warns"]]
|
|
300
|
+
return "\n".join(L) + "\n"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main(argv=None):
|
|
304
|
+
ap = argparse.ArgumentParser(description="Check an exam paper and its mark scheme.")
|
|
305
|
+
ap.add_argument("paper")
|
|
306
|
+
ap.add_argument("--scheme", help="the mark scheme (.md, .txt or .docx)")
|
|
307
|
+
ap.add_argument("--spec", help="spec.json: duration_minutes, total, objectives {name: target %%}, assesses {part: [objectives]}")
|
|
308
|
+
ap.add_argument("--json", action="store_true")
|
|
309
|
+
args = ap.parse_args(argv)
|
|
310
|
+
try:
|
|
311
|
+
paper = parse(read_text(args.paper))
|
|
312
|
+
scheme = parse(read_text(args.scheme)) if args.scheme else None
|
|
313
|
+
spec = None
|
|
314
|
+
if args.spec:
|
|
315
|
+
with open(args.spec, encoding="utf-8-sig") as f:
|
|
316
|
+
spec = json.load(f)
|
|
317
|
+
if not isinstance(spec, dict):
|
|
318
|
+
raise ValueError("spec.json must be an object")
|
|
319
|
+
except (OSError, ValueError, UnicodeDecodeError, zipfile.BadZipFile, KeyError, ElementTree.ParseError) as e:
|
|
320
|
+
print(f"FAIL cannot read input: {e}")
|
|
321
|
+
return 2
|
|
322
|
+
r = check(paper, scheme, spec)
|
|
323
|
+
print(json.dumps(r, indent=2) if args.json else report(r), end="" if not args.json else "\n")
|
|
324
|
+
return 1 if r["fails"] else 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
sys.exit(main())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"duration_minutes": 50,
|
|
3
|
+
"total": 50,
|
|
4
|
+
"objectives": {"AO1": 40, "AO2": 40, "AO3": 20},
|
|
5
|
+
"assesses": {
|
|
6
|
+
"1a": "AO1", "1b": "AO2", "1c": "AO1",
|
|
7
|
+
"2a(i)": "AO1", "2a(ii)": "AO2", "2a(iii)": "AO2", "2b": "AO3",
|
|
8
|
+
"3a": "AO1", "3b": "AO3", "3c": "AO2",
|
|
9
|
+
"4": ["AO1", "AO3"]
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Legal groundwork for a startup that can't yet pay a lawyer for everything: an agreement checked before you sign it (blanks, broken cross-references, mismatched amounts, the renewal date, and the clauses that hurt a small company, with wording to ask for),
|
|
2
|
+
"description": "Legal groundwork for a startup that can't yet pay a lawyer for everything: an agreement checked before you sign it (blanks, broken cross-references, mismatched amounts, the renewal date, and the clauses that hurt a small company, with wording to ask for), your cap table modelled through SAFEs, notes and a priced round, vesting schedules with leavers, acceleration and the 83(b) deadline, and your first office lease turned into its real commitment and exit dates. Each says what is worth a lawyer's hour.",
|
|
3
3
|
"display_name": "Startup legal",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"legal",
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"NDA",
|
|
10
10
|
"SAFE",
|
|
11
11
|
"cap table",
|
|
12
|
-
"dilution"
|
|
12
|
+
"dilution",
|
|
13
|
+
"vesting",
|
|
14
|
+
"83(b)",
|
|
15
|
+
"lease"
|
|
13
16
|
],
|
|
14
17
|
"links": {
|
|
15
18
|
"documentation": "https://sananthanarayan.github.io/skilldrop/packs/startup-legal/"
|
|
@@ -37,7 +40,9 @@
|
|
|
37
40
|
"when": "You're a founder without a lawyer on retainer, and an agreement, a round or a launch is coming.",
|
|
38
41
|
"you_bring": [
|
|
39
42
|
"the agreement you've been sent, or your own template",
|
|
40
|
-
"your cap table, SAFEs and any term sheet"
|
|
43
|
+
"your cap table, SAFEs and any term sheet",
|
|
44
|
+
"grant documents for anyone with vesting shares or options",
|
|
45
|
+
"the heads of terms or draft lease for an office"
|
|
41
46
|
],
|
|
42
47
|
"steps": [
|
|
43
48
|
{
|
|
@@ -51,8 +56,19 @@
|
|
|
51
56
|
"you_do": "Give it your shareholders, SAFEs, notes and the term sheet.",
|
|
52
57
|
"you_get": "What each founder and investor owns after the round, what each SAFE converted at, and what the pool top-up cost you.",
|
|
53
58
|
"decide": "Which valuation and pool terms to negotiate for."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"skill": "vesting-schedule",
|
|
62
|
+
"you_do": "Give it each grant's shares, start date and terms, and any leaving date.",
|
|
63
|
+
"you_get": "Cliff and fully-vested dates, what is vested on any date, leaver outcomes, and the 83(b) deadline as an exact date."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"skill": "lease-check",
|
|
67
|
+
"you_do": "Give it the heads of terms or the draft lease.",
|
|
68
|
+
"you_get": "What signing commits you to up to each break and to the end, every notice deadline as a date, and the clauses to push back on with wording.",
|
|
69
|
+
"decide": "Sign, negotiate, or take the guarantee or break clause to a lawyer."
|
|
54
70
|
}
|
|
55
71
|
],
|
|
56
|
-
"you_end_with": "Agreements you understand before signing,
|
|
72
|
+
"you_end_with": "Agreements you understand before signing, a cap table and vesting schedule you can defend, and an office lease whose cost and exits you know."
|
|
57
73
|
}
|
|
58
74
|
}
|
|
@@ -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.
|