skilldrop-cli 1.7.0 → 1.9.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 (59) hide show
  1. package/README.md +3 -1
  2. package/catalogue.json +16 -1
  3. package/model-routing.json +12 -0
  4. package/package.json +1 -1
  5. package/packs/event-planner/skills/run-of-show/SKILL.md +6 -3
  6. package/packs/event-planner/skills/run-of-show/manifest.json +1 -1
  7. package/packs/event-planner/skills/seating-plan/SKILL.md +6 -3
  8. package/packs/event-planner/skills/seating-plan/evals/evals.json +30 -0
  9. package/packs/event-planner/skills/seating-plan/evals/files/10/hall_notes.txt +13 -0
  10. package/packs/event-planner/skills/seating-plan/evals/files/10/quiz_night_registrations.csv +67 -0
  11. package/packs/event-planner/skills/seating-plan/evals/files/11/notes_for_the_plan.txt +21 -0
  12. package/packs/event-planner/skills/seating-plan/evals/files/11/rsvp_list.csv +77 -0
  13. package/packs/event-planner/skills/seating-plan/evals/files/11/table_plan_draft.csv +78 -0
  14. package/packs/event-planner/skills/seating-plan/manifest.json +1 -1
  15. package/packs/interior-designer/pack.json +60 -0
  16. package/packs/interior-designer/skills/furniture-layout/SKILL.md +182 -0
  17. package/packs/interior-designer/skills/furniture-layout/evals/eval_queries.json +11 -0
  18. package/packs/interior-designer/skills/furniture-layout/evals/evals.json +133 -0
  19. package/packs/interior-designer/skills/furniture-layout/evals/files/2/living-room-notes.md +24 -0
  20. package/packs/interior-designer/skills/furniture-layout/evals/files/3/office-brief.md +18 -0
  21. package/packs/interior-designer/skills/furniture-layout/evals/files/4/furniture_schedule.csv +15 -0
  22. package/packs/interior-designer/skills/furniture-layout/evals/files/4/site_survey.txt +22 -0
  23. package/packs/interior-designer/skills/furniture-layout/evals/files/5/layout_v2.csv +14 -0
  24. package/packs/interior-designer/skills/furniture-layout/evals/files/5/measurements.txt +16 -0
  25. package/packs/interior-designer/skills/furniture-layout/evals/files/6/access_consultant_email.txt +19 -0
  26. package/packs/interior-designer/skills/furniture-layout/evals/files/6/furniture_stock.csv +5 -0
  27. package/packs/interior-designer/skills/furniture-layout/evals/files/6/site_measurements.txt +14 -0
  28. package/packs/interior-designer/skills/furniture-layout/evals/files/7/room_214_layout.csv +11 -0
  29. package/packs/interior-designer/skills/furniture-layout/evals/files/7/room_and_brand_standard.txt +20 -0
  30. package/packs/interior-designer/skills/furniture-layout/evals/files/8/email_from_practice_manager.txt +44 -0
  31. package/packs/interior-designer/skills/furniture-layout/evals/files/8/furniture_we_are_keeping.csv +10 -0
  32. package/packs/interior-designer/skills/furniture-layout/evals/files/9/reading_room_schedule.csv +15 -0
  33. package/packs/interior-designer/skills/furniture-layout/evals/files/9/room_notes.txt +23 -0
  34. package/packs/interior-designer/skills/furniture-layout/examples/king-bed-bedroom.md +121 -0
  35. package/packs/interior-designer/skills/furniture-layout/examples/king-bed-bedroom.svg +57 -0
  36. package/packs/interior-designer/skills/furniture-layout/manifest.json +44 -0
  37. package/packs/interior-designer/skills/furniture-layout/scripts/layout_check.py +1130 -0
  38. package/packs/interior-designer/skills/furniture-layout/templates/layout.json +26 -0
  39. package/packs/operations-manager/skills/shift-roster/SKILL.md +6 -3
  40. package/packs/operations-manager/skills/shift-roster/manifest.json +1 -1
  41. package/packs/startup-legal/pack.json +58 -0
  42. package/packs/startup-legal/skills/cap-table/SKILL.md +131 -0
  43. package/packs/startup-legal/skills/cap-table/evals/eval_queries.json +30 -0
  44. package/packs/startup-legal/skills/cap-table/evals/evals.json +46 -0
  45. package/packs/startup-legal/skills/cap-table/examples/seed-to-series-a.md +81 -0
  46. package/packs/startup-legal/skills/cap-table/manifest.json +34 -0
  47. package/packs/startup-legal/skills/cap-table/scripts/cap_table.py +344 -0
  48. package/packs/startup-legal/skills/cap-table/templates/cap.json +23 -0
  49. package/packs/startup-legal/skills/contract-check/SKILL.md +118 -0
  50. package/packs/startup-legal/skills/contract-check/evals/eval_queries.json +30 -0
  51. package/packs/startup-legal/skills/contract-check/evals/evals.json +46 -0
  52. package/packs/startup-legal/skills/contract-check/evals/files/1/saas-agreement.md +68 -0
  53. package/packs/startup-legal/skills/contract-check/evals/files/2/consulting-agreement.md +64 -0
  54. package/packs/startup-legal/skills/contract-check/evals/files/3/reseller-agreement.md +69 -0
  55. package/packs/startup-legal/skills/contract-check/examples/inputs/saas-agreement.md +68 -0
  56. package/packs/startup-legal/skills/contract-check/examples/saas-agreement.md +72 -0
  57. package/packs/startup-legal/skills/contract-check/manifest.json +34 -0
  58. package/packs/startup-legal/skills/contract-check/reference.md +109 -0
  59. package/packs/startup-legal/skills/contract-check/scripts/contract_check.py +413 -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())