skilldrop-cli 1.10.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.
Files changed (34) hide show
  1. package/README.md +3 -2
  2. package/catalogue.json +9 -1
  3. package/model-routing.json +8 -0
  4. package/package.json +1 -1
  5. package/packs/educator/pack.json +25 -0
  6. package/packs/educator/skills/exam-paper-check/SKILL.md +125 -0
  7. package/packs/educator/skills/exam-paper-check/evals/eval_queries.json +30 -0
  8. package/packs/educator/skills/exam-paper-check/evals/evals.json +73 -0
  9. package/packs/educator/skills/exam-paper-check/evals/files/1/year9-science-scheme.md +16 -0
  10. package/packs/educator/skills/exam-paper-check/evals/files/1/year9-science.md +58 -0
  11. package/packs/educator/skills/exam-paper-check/evals/files/2/mock-paper1-ms.md +32 -0
  12. package/packs/educator/skills/exam-paper-check/evals/files/2/mock-paper1.md +60 -0
  13. package/packs/educator/skills/exam-paper-check/evals/files/3/hist1042-quiz-markscheme.md +37 -0
  14. package/packs/educator/skills/exam-paper-check/evals/files/3/hist1042-quiz.md +50 -0
  15. package/packs/educator/skills/exam-paper-check/evals/files/4/chem-mock-paper.md +51 -0
  16. package/packs/educator/skills/exam-paper-check/evals/files/4/chem-mock-scheme.md +32 -0
  17. package/packs/educator/skills/exam-paper-check/evals/files/5/econ-p2-paper.md +57 -0
  18. package/packs/educator/skills/exam-paper-check/evals/files/5/econ-p2-scheme.md +34 -0
  19. package/packs/educator/skills/exam-paper-check/examples/inputs/spec.json +3 -0
  20. package/packs/educator/skills/exam-paper-check/examples/inputs/year9-science-scheme.md +16 -0
  21. package/packs/educator/skills/exam-paper-check/examples/inputs/year9-science.md +58 -0
  22. package/packs/educator/skills/exam-paper-check/examples/year9-forces.md +68 -0
  23. package/packs/educator/skills/exam-paper-check/manifest.json +33 -0
  24. package/packs/educator/skills/exam-paper-check/scripts/exam_check.py +328 -0
  25. package/packs/educator/skills/exam-paper-check/templates/spec.json +11 -0
  26. package/packs/startup-legal/pack.json +10 -2
  27. package/packs/startup-legal/skills/vesting-schedule/SKILL.md +122 -0
  28. package/packs/startup-legal/skills/vesting-schedule/evals/eval_queries.json +30 -0
  29. package/packs/startup-legal/skills/vesting-schedule/evals/evals.json +70 -0
  30. package/packs/startup-legal/skills/vesting-schedule/examples/cofounder-leaving.md +66 -0
  31. package/packs/startup-legal/skills/vesting-schedule/examples/inputs/vesting.json +8 -0
  32. package/packs/startup-legal/skills/vesting-schedule/manifest.json +33 -0
  33. package/packs/startup-legal/skills/vesting-schedule/scripts/vesting.py +280 -0
  34. 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), your cap table modelled through SAFEs, notes and a priced round, and your first office lease turned into its real commitment and exit dates. Each says what is worth a lawyer's hour.",
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",
@@ -10,6 +10,8 @@
10
10
  "SAFE",
11
11
  "cap table",
12
12
  "dilution",
13
+ "vesting",
14
+ "83(b)",
13
15
  "lease"
14
16
  ],
15
17
  "links": {
@@ -39,6 +41,7 @@
39
41
  "you_bring": [
40
42
  "the agreement you've been sent, or your own template",
41
43
  "your cap table, SAFEs and any term sheet",
44
+ "grant documents for anyone with vesting shares or options",
42
45
  "the heads of terms or draft lease for an office"
43
46
  ],
44
47
  "steps": [
@@ -54,6 +57,11 @@
54
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.",
55
58
  "decide": "Which valuation and pool terms to negotiate for."
56
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
+ },
57
65
  {
58
66
  "skill": "lease-check",
59
67
  "you_do": "Give it the heads of terms or the draft lease.",
@@ -61,6 +69,6 @@
61
69
  "decide": "Sign, negotiate, or take the guarantee or break clause to a lawyer."
62
70
  }
63
71
  ],
64
- "you_end_with": "Agreements you understand before signing, a cap table you can defend, and an office lease whose cost and exits you know."
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."
65
73
  }
66
74
  }
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: vesting-schedule
3
+ description: Work out vesting for founder stock, restricted stock, options and RSUs — each grant's cliff date and amount, every vesting date in whole shares, what is vested on any date, what happens when someone leaves (vested, forfeited or repurchasable, and the last day to exercise), single- and double-trigger acceleration on a sale, and the US 83(b) election deadline moved past weekends and holidays — computed by a script that keeps the totals exact. Use when a founder asks "how much has my co-founder vested", "what happens to her shares if she leaves", "when does the cliff hit", "build our vesting schedule", "when is the 83(b) deadline", or "what does double-trigger acceleration give us in a sale".
4
+ ---
5
+
6
+ # vesting-schedule
7
+
8
+ Founders and early employees ask the same questions at the worst moments: a co-founder is
9
+ leaving, an acquirer is asking, a grant was signed last week and nobody remembers the 83(b).
10
+ The answers are date arithmetic that is easy to get wrong in ways that matter: a vesting start
11
+ on the 31st in a month with 30 days, a cliff that falls between two quarterly dates, rounding
12
+ that runs ahead of the grant, a 30-day deadline that lands on the Fourth of July. A script
13
+ computes every grant exactly; the agent turns the founder's documents into its input and the
14
+ result into the answer they need.
15
+
16
+ ## How to respond
17
+
18
+ **These rules come before the steps and outrank them:**
19
+
20
+ - **Answer the question asked, first.** ✅ *"If Sam leaves on 20 August, 45,000 of their
21
+ 120,000 options have vested and the other 75,000 lapse. They have until 18 November to
22
+ exercise."* Never mention a script, a JSON file, this skill's name, a path inside its folder
23
+ or a temporary path, and never call your work "the check" or "a tool".
24
+ - **Every share count and date comes from the computed schedule,** run on the final inputs.
25
+ Each scenario the person asks about (a leaving date, a closing date, "what if she's let go
26
+ in July instead") is its own run with that date. Never count months by hand: a 30-month
27
+ answer written as 31 is the commonest error in this work.
28
+ - **Stay on the questions asked,** in their order, and answer each fully. For a leaver that
29
+ means what happens to the unvested shares: who can buy them back, at what price if the
30
+ documents say, and the deadline for doing it as a date, against today's date, plus the last
31
+ day to exercise vested options.
32
+ - **The 83(b) deadline is never approximate.** Give the date, say it is 30 days from the grant
33
+ (or from an early exercise), and when day 30 falls on a weekend or federal holiday, say that
34
+ the deadline moves to the next business day and advise filing well before day 30 anyway.
35
+ When it has passed, say so plainly and what that means: the shares are taxed as they vest
36
+ instead of at grant. Recommend a tax adviser only for what to do next, not for the date.
37
+ - **Say which terms you assumed.** The vesting start (often earlier than the grant date), the
38
+ cliff, monthly or quarterly vesting, the exercise window after leaving (90 days is usual), the
39
+ company's buy-back window, what counts as a "good reason" resignation for double-trigger, and
40
+ how acceleration is ordered (single trigger here applies to what is unvested at the sale;
41
+ documents sometimes accelerate from the end of the schedule instead). Tag each `[assumption]` and
42
+ give the figure under the other reading when it changes the answer.
43
+ - **Name what is worth a lawyer's hour, and only that.** One or two lines: a leaver dispute,
44
+ acceleration terms an acquirer is pushing back on, a missed 83(b). Never refuse, never open
45
+ with a disclaimer.
46
+ - **Working files stay out of sight.** The input goes in a private temporary folder
47
+ (`mktemp -d`) and is deleted before replying. A spreadsheet they asked for goes in their
48
+ working directory under a plain name.
49
+
50
+ 1. **Gather each grant** from the documents or the conversation: holder, type (founder stock,
51
+ restricted stock, option, RSU), shares, grant date, vesting start, months, cliff, frequency,
52
+ acceleration (single trigger: on a sale; double trigger: a sale and then losing the job within
53
+ a window), any leaving date, any early exercise, and whether an 83(b) was filed. Defaults
54
+ when unsaid: 48 months, a 12-month cliff, monthly, US. Ask at most two questions, only for a
55
+ missing share count or start date.
56
+
57
+ 2. **Write the input** in the shape of [`templates/vesting.json`](templates/vesting.json). A sale
58
+ goes in `change_of_control` at the top.
59
+
60
+ 3. **Run it** on the date they care about (today, a leaving date, a closing date).
61
+
62
+ ```bash
63
+ work=$(mktemp -d 2>/dev/null || mktemp -d ./.work.XXXXXX) # a private folder for this run only
64
+ # Claude Code
65
+ python3 "${CLAUDE_SKILL_DIR}/scripts/vesting.py" "$work/vesting.json" --as-of 2027-06-30
66
+ # Other IDEs (from the skill folder); --csv writes every vesting date
67
+ python3 scripts/vesting.py "$work/vesting.json" --csv vesting-schedule.csv --full
68
+ ```
69
+
70
+ Exit 0 means computed, 1 means a check failed (a schedule that doesn't sum to its grant, or a
71
+ passed 83(b) deadline), 2 means the input can't be read. Rerun with the other reading of any
72
+ assumption that changes the answer.
73
+
74
+ 4. **Reply in this order:**
75
+ 1. **The answer** to their question, with the numbers and dates.
76
+ 2. **Each grant's summary:** cliff date and amount, fully vested date, vested and unvested now.
77
+ 3. **Deadlines:** 83(b) dates, last days to exercise.
78
+ 4. **The schedule,** yearly in the reply (every date in a spreadsheet when asked).
79
+ 5. **Assumptions,** one line each.
80
+ 6. **What to have a lawyer or tax adviser look at,** one or two lines.
81
+
82
+ **Non-interactive runs** (subagent, CI, headless): unsaid terms take the defaults above, tagged
83
+ `[assumption]`. With no share count or no start date for a grant, emit `BLOCKED: need each
84
+ grant's shares and vesting start date`.
85
+
86
+ ## Useful references in this skill
87
+
88
+ - [`templates/vesting.json`](templates/vesting.json) — the input: a founder with double-trigger acceleration, an engineer who leaves, an advisor on quarterly vesting
89
+ - [`scripts/vesting.py`](scripts/vesting.py) — cliffs, month-end dates, whole-share rounding, leavers, acceleration, 83(b) deadlines with US holidays
90
+ - [`examples/cofounder-leaving.md`](examples/cofounder-leaving.md) — worked example: a co-founder leaving 14 months in, with a missed 83(b) question
91
+
92
+ ## Quality bar
93
+
94
+ - **Every share count and date comes from the computed schedule,** and each grant's dates sum to its shares.
95
+ - **A start on the 29th–31st vests on the last day of shorter months,** and leap years count.
96
+ - **The 83(b) deadline is an exact date,** moved past weekends and federal holidays, and a passed one is said plainly.
97
+ - **Leaving gives vested, unvested and the exercise deadline,** each from the leaving date, not today.
98
+ - **Every assumed term is stated,** with the other reading's figure when it changes the answer.
99
+ - **The reply answers the question first,** with no mention of scripts or files.
100
+
101
+ ## When to use this skill
102
+
103
+ - ✅ A co-founder or early employee is leaving and everyone needs the numbers
104
+ - ✅ A new grant was signed: when does it cliff, and when is the 83(b) due
105
+ - ✅ An acquirer or investor asks what vests on a sale
106
+ - ✅ Building the schedule for a founder agreement or an option plan
107
+
108
+ ## When NOT to use this skill
109
+
110
+ - ❌ Tax: what an 83(b) or an exercise costs, ISOs against NSOs, EMI or ESOP rules
111
+ - ❌ Ownership after a funding round: that's a cap table
112
+ - ❌ Drafting the stock purchase or option agreement
113
+ - ❌ Valuing the shares or setting a strike price
114
+
115
+ ## Anti-patterns to avoid
116
+
117
+ - ❌ **"About 30 days from the grant."** The 83(b) date is exact, and a weekend moves it.
118
+ - ❌ **Vesting from the grant date when the agreement says an earlier start.** Founders often backdate the start to when they began.
119
+ - ❌ **Rounding each month up.** A schedule of 2,500.4 shares a month that rounds up overshoots the grant; round the running total down and settle on the last date.
120
+ - ❌ **Treating double-trigger as automatic on a sale.** It needs the second trigger.
121
+ - ❌ **Counting the leaver's vesting to today** instead of to their leaving date.
122
+ - ❌ **"The schedule shows…"** The founder wants the number.
@@ -0,0 +1,30 @@
1
+ [
2
+ {
3
+ "query": "How much has my co-founder vested if she leaves next month?",
4
+ "should_trigger": true
5
+ },
6
+ {
7
+ "query": "When is the 83(b) deadline for the stock we signed last week?",
8
+ "should_trigger": true
9
+ },
10
+ {
11
+ "query": "Build the vesting schedule for our first three hires' options",
12
+ "should_trigger": true
13
+ },
14
+ {
15
+ "query": "What does double-trigger acceleration give our team if we're acquired?",
16
+ "should_trigger": true
17
+ },
18
+ {
19
+ "query": "How much of the company will I own after the Series A?",
20
+ "should_trigger": false
21
+ },
22
+ {
23
+ "query": "Review this employment contract before I send it",
24
+ "should_trigger": false
25
+ },
26
+ {
27
+ "query": "What strike price should our options have?",
28
+ "should_trigger": false
29
+ }
30
+ ]
@@ -0,0 +1,70 @@
1
+ {
2
+ "skill_name": "vesting-schedule",
3
+ "evals": [
4
+ {
5
+ "id": 1,
6
+ "prompt": "My co-founder Alex is leaving on 30 September 2026. 3,000,000 founder shares, four-year vesting, one-year cliff, monthly, from 1 July 2025; the stock purchase agreement was signed 15 September 2025. How much does Alex keep, and does it matter if Alex never filed the 83(b)?",
7
+ "assertions": [
8
+ "Says Alex keeps 875,000 shares and 2,125,000 are unvested and can be bought back",
9
+ "Gives the cliff as 1 July 2026 with 750,000 shares",
10
+ "Gives the 83(b) deadline as 15 October 2025, 30 days after the purchase, and says it has passed",
11
+ "Explains plainly what a missed 83(b) means: each vesting tranche is taxed as it vests",
12
+ "Does not mention a script or the skill's name, and does not invent terms (acceleration, repurchase price) not in the prompt"
13
+ ]
14
+ },
15
+ {
16
+ "id": 2,
17
+ "held_out": "2026-11-08",
18
+ "prompt": "my cofounder Dana Okafor has 850,000 founder shares, 4 year vesting with a 1 year cliff, monthly after that, vesting start 31 Jan 2024. she's leaving on 20 Sep 2026. how much has she vested, when did the cliff hit, and what happens to the rest?",
19
+ "assertions": [
20
+ "Cliff is 31 January 2025, when 212,500 shares vest (850,000 x 12 / 48)",
21
+ "Handles the 31st start in short months: the February 2025 vesting date is 28 February 2025 (not 3 March or skipped), with cumulative vested 230,208 (floor of 850,000 x 13 / 48); later dates fall on the last day of shorter months, e.g. 30 June",
22
+ "Vested on leaving (20 September 2026) is 548,958 shares - 31 months, last vesting date 31 August 2026 (floor of 850,000 x 31 / 48 = 548,958.33); accept 548,959 only if a different rounding is stated",
23
+ "The September 2026 vesting date (30 September 2026) falls after she leaves, so it does not vest",
24
+ "Unvested shares are 301,042 (850,000 - 548,958), and the answer says that for founder stock these are normally subject to the company's repurchase right (typically at the original price) or forfeited under the stock agreement, rather than describing an option exercise window",
25
+ "Does not invent acceleration, a repurchase price, a leaving reason, or 83(b) facts that are not in the prompt, and says the stock agreement governs"
26
+ ]
27
+ },
28
+ {
29
+ "id": 3,
30
+ "held_out": "2026-11-08",
31
+ "prompt": "We're Larkspur Robotics, Inc. (Delaware) and I need our vesting worked out before a board meeting. Grants:\n\n1. Mei Tanaka (co-founder, CEO): 3,000,000 shares of restricted common stock, vesting commencement date 1 March 2025, 48 months, 12-month cliff, then monthly. Double-trigger acceleration: 100% of unvested shares vest if she is terminated without cause within 12 months after a change of control.\n2. Tomas Herrera (co-founder, CTO): 2,000,000 shares of restricted common stock, same schedule and start date as Mei. Single-trigger acceleration: 25% of his then-unvested shares vest on a change of control. No other acceleration.\n3. Noor Haddad (joining as third co-founder): buys 650,000 shares of restricted common stock on 25 November 2026, vesting commencement date 25 November 2026, 48 months, 12-month cliff, then monthly. No acceleration.\n\nQuestions: (a) What is Noor's 83(b) filing deadline? (b) When is her cliff and how many shares vest then? (c) We have an offer that would close on 15 June 2027. What does each of the three have vested at closing, after any acceleration? (d) If the buyer terminates Mei without cause on 30 September 2027, what does she end up with? (e) What if instead they terminate her without cause on 20 July 2028?",
32
+ "assertions": [
33
+ "Noor's 83(b) deadline: 30 days after 25 November 2026 is Friday 25 December 2026, Christmas Day (a federal holiday), followed by a weekend, so the deadline moves to Monday 28 December 2026; the answer advises filing well before it",
34
+ "Noor's cliff is 25 November 2027 with 162,500 shares (650,000 x 12 / 48); at the 15 June 2027 closing she has 0 vested and, having no acceleration, still 0 after closing",
35
+ "At closing on 15 June 2027 (27 months, last vesting date 1 June 2027): Mei has 1,687,500 vested (3,000,000 x 27 / 48) and the sale alone does not accelerate her shares (double-trigger needs the termination too)",
36
+ "Tomas at closing: 1,125,000 vested by schedule (2,000,000 x 27 / 48) plus 25% of his 875,000 unvested = 218,750 accelerated, giving 1,343,750; applies the 25% to unvested shares, not to the whole grant (which would wrongly give 500,000)",
37
+ "Mei terminated without cause on 30 September 2027 is within 12 months of closing, so double-trigger applies: 1,875,000 vested by schedule (30 months, last vest 1 September 2027) plus 1,125,000 accelerated = all 3,000,000 shares vested",
38
+ "Mei terminated on 20 July 2028 is outside the 12-month window (which ended 15 June 2028), so there is no acceleration: 2,500,000 vested (40 months, last vest 1 July 2028) and 500,000 unvested, subject to repurchase or forfeiture",
39
+ "Does not invent an option exercise window for this restricted stock, acceleration for Noor, a purchase or repurchase price, or deal terms not in the prompt"
40
+ ]
41
+ },
42
+ {
43
+ "id": 4,
44
+ "held_out": "2026-11-09",
45
+ "prompt": "My co-founder Ravi Okafor has 1,250,000 founder shares, 4-year vesting, 1-year cliff, monthly after that, vesting start 31 Aug 2025. He signed his stock purchase agreement on 27 Oct 2026 and hasn't filed an 83(b) yet. Today is 9 Nov 2026. How much has he vested, when did the cliff hit, and what's his 83(b) deadline?",
46
+ "assertions": [
47
+ "Cliff date is 31 Aug 2026, with 312,500 shares vesting then (1,250,000 x 12 / 48)",
48
+ "Vested today (9 Nov 2026) is 364,583 shares: 14 monthly dates have passed (to 31 Oct 2026), floor(1,250,000 x 14 / 48) = floor(364,583.33); unvested 885,417",
49
+ "Vesting dates fall on the 31st or the last day of shorter months (30 Sep 2025, 30 Nov 2026, 28 Feb 2027 etc.); the next vesting is 30 Nov 2026 for 26,042 shares (390,625 - 364,583), and vesting finishes on 31 Aug 2029",
50
+ "83(b) deadline: 30 days after the 27 Oct 2026 transfer is Thursday 26 Nov 2026, which is Thanksgiving (a US federal holiday), so the deadline moves to Friday 27 Nov 2026",
51
+ "Says the 30-day clock runs from the 27 Oct 2026 purchase/transfer date, not the 31 Aug 2025 vesting start, and urges filing promptly since the deadline cannot be extended",
52
+ "Does not invent a purchase price, a tax amount, acceleration terms or other details not in the prompt"
53
+ ]
54
+ },
55
+ {
56
+ "id": 5,
57
+ "held_out": "2026-11-09",
58
+ "prompt": "We're Halcyon Loom Inc. and we're in the middle of an acquisition, so I need two vesting questions answered precisely. Today is 9 Nov 2026.\n\n1. Lena Varga, a co-founder, got 1,100,000 shares of restricted stock with a vesting start of 29 March 2024: 48 months, 1-year cliff, monthly vesting after. She resigned effective 4 September 2026. Her stock purchase agreement gives the company the right to repurchase unvested shares at her original price, exercisable by written notice within 60 days of her termination date. We haven't sent any notice. How many shares has she vested, how many can we buy back, and are we still in time?\n\n2. Jonah Pike, an early employee, holds an option over 50,000 shares, vesting start 31 January 2025, same 4-year / 1-year-cliff / monthly terms. His grant has double-trigger acceleration: 100% of unvested shares vest if he is terminated without cause within 12 months after a change of control. The sale is due to close on 15 December 2026 and the buyer says it will keep him. How much is vested at closing, does his acceleration kick in at closing, and if the buyer lets him go without cause on 10 March 2027, how much will he have and what's his last day to exercise? His option agreement gives 90 days after termination to exercise.",
59
+ "assertions": [
60
+ "Lena: vesting dates fall on the 29th, except 28 Feb 2025 (and other Februaries); the cliff was 29 Mar 2025; at her 4 Sep 2026 departure 29 months had vested (last date 29 Aug 2026): floor(1,100,000 x 29 / 48) = floor(664,583.33) = 664,583 vested, leaving 435,417 unvested shares subject to repurchase",
61
+ "Lena's repurchase window: 60 days after 4 Sep 2026 is 3 Nov 2026, which is before today (9 Nov 2026), so the window closed 6 days ago with no notice sent; says the company appears to have lost the right under the agreement as described and should check the agreement's exact wording with counsel, rather than telling them they can still buy back",
62
+ "Jonah at closing (15 Dec 2026): 22 monthly dates have passed (last 30 Nov 2026, the month-end because November has 30 days): floor(50,000 x 22 / 48) = floor(22,916.67) = 22,916 vested; the cliff was 31 Jan 2026 with 12,500 vesting",
63
+ "States the double trigger is not met at closing because he is kept on (sale alone is one trigger), so nothing accelerates at closing",
64
+ "If terminated without cause on 10 Mar 2027 (85 days after closing, within the 12-month window), both triggers are met: without acceleration he would have 26,041 vested (25 months, last date 28 Feb 2027, floor(50,000 x 25 / 48)); acceleration vests the remaining 23,959, so all 50,000 are vested",
65
+ "Jonah's last day to exercise is 8 Jun 2027 (90 days after 10 Mar 2027)",
66
+ "Does not invent repurchase prices, exercise prices, tax figures, deal value or terms not in the prompt"
67
+ ]
68
+ }
69
+ ]
70
+ }