skilldrop-cli 1.0.0 → 1.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilldrop-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Portable AI-agent skills, measured against the agent without them. ADRs, PRDs, runbooks, decks and reviews, installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
5
5
  "author": {
6
6
  "name": "Sanjay Ananthanarayan",
@@ -6,22 +6,22 @@ A small synthetic event log demonstrating the three views — including graceful
6
6
 
7
7
  ```csv
8
8
  timestamp,user,tool,session_id,output_consumed,consumed_at,outcome
9
- 2026-05-19T09:14:23Z,alice@acme.com,claude-code,sess-001,1,2026-05-19T11:02:00Z,success
10
- 2026-05-19T09:14:55Z,alice@acme.com,claude-code,sess-001,1,2026-05-19T11:02:00Z,success
11
- 2026-05-19T09:18:02Z,alice@acme.com,deck-builder,sess-001,1,2026-05-19T14:30:00Z,success
12
- 2026-05-19T10:01:11Z,bob@acme.com,cursor,sess-014,0,,success
13
- 2026-05-19T10:01:30Z,bob@acme.com,cursor,sess-015,0,,success
14
- 2026-05-19T10:02:11Z,bob@acme.com,cursor,sess-016,0,,success
15
- 2026-05-19T10:03:11Z,bob@acme.com,cursor,sess-017,0,,success
16
- 2026-05-19T10:04:11Z,bob@acme.com,cursor,sess-018,0,,success
17
- 2026-05-20T14:22:08Z,carol@acme.com,claude-code,sess-029,1,2026-05-22T08:11:00Z,success
18
- 2026-05-20T15:22:08Z,carol@acme.com,adr-generator,sess-029,1,2026-05-22T08:11:00Z,success
19
- 2026-05-20T16:22:08Z,carol@acme.com,design-doc,sess-030,1,2026-05-22T08:11:00Z,success
20
- 2026-05-19T11:14:23Z,dan@acme.com,claude-code,sess-101,1,2026-05-19T13:00:00Z,success
21
- 2026-05-19T11:15:23Z,dan@acme.com,claude-code,sess-101,0,,success
22
- 2026-05-20T08:14:23Z,eve@acme.com,claude-code,sess-201,1,2026-05-20T09:00:00Z,success
23
- 2026-05-20T08:15:23Z,eve@acme.com,claude-code,sess-201,1,2026-05-20T09:00:00Z,success
24
- 2026-05-20T08:16:23Z,eve@acme.com,cursor,sess-202,1,2026-05-20T10:00:00Z,success
9
+ 2026-05-19T09:14:23Z,alice@acme.example,claude-code,sess-001,1,2026-05-19T11:02:00Z,success
10
+ 2026-05-19T09:14:55Z,alice@acme.example,claude-code,sess-001,1,2026-05-19T11:02:00Z,success
11
+ 2026-05-19T09:18:02Z,alice@acme.example,deck-builder,sess-001,1,2026-05-19T14:30:00Z,success
12
+ 2026-05-19T10:01:11Z,bob@acme.example,cursor,sess-014,0,,success
13
+ 2026-05-19T10:01:30Z,bob@acme.example,cursor,sess-015,0,,success
14
+ 2026-05-19T10:02:11Z,bob@acme.example,cursor,sess-016,0,,success
15
+ 2026-05-19T10:03:11Z,bob@acme.example,cursor,sess-017,0,,success
16
+ 2026-05-19T10:04:11Z,bob@acme.example,cursor,sess-018,0,,success
17
+ 2026-05-20T14:22:08Z,carol@acme.example,claude-code,sess-029,1,2026-05-22T08:11:00Z,success
18
+ 2026-05-20T15:22:08Z,carol@acme.example,adr-generator,sess-029,1,2026-05-22T08:11:00Z,success
19
+ 2026-05-20T16:22:08Z,carol@acme.example,design-doc,sess-030,1,2026-05-22T08:11:00Z,success
20
+ 2026-05-19T11:14:23Z,dan@acme.example,claude-code,sess-101,1,2026-05-19T13:00:00Z,success
21
+ 2026-05-19T11:15:23Z,dan@acme.example,claude-code,sess-101,0,,success
22
+ 2026-05-20T08:14:23Z,eve@acme.example,claude-code,sess-201,1,2026-05-20T09:00:00Z,success
23
+ 2026-05-20T08:15:23Z,eve@acme.example,claude-code,sess-201,1,2026-05-20T09:00:00Z,success
24
+ 2026-05-20T08:16:23Z,eve@acme.example,cursor,sess-202,1,2026-05-20T10:00:00Z,success
25
25
  ```
26
26
 
27
27
  16 events, 5 users, 5 tools, period 2026-05-19 → 2026-05-20.
@@ -56,11 +56,11 @@ python3 scripts/build_report.py events.csv \
56
56
 
57
57
  | User | Invocations | Sessions | Avg / session | Distinct tools | Effectiveness rate | Single-shot rate |
58
58
  |---|---:|---:|---:|---:|---:|---:|
59
- | alice@acme.com | 3 | 1 | 3.0 | 2 | 100% | 0% |
60
- | bob@acme.com | 5 | 5 | 1.0 | 1 | 0% | 100% |
61
- | carol@acme.com | 3 | 2 | 1.5 | 3 | 100% | 50% |
62
- | dan@acme.com | 2 | 1 | 2.0 | 1 | 50% | 0% |
63
- | eve@acme.com | 3 | 2 | 1.5 | 2 | 100% | 50% |
59
+ | alice@acme.example | 3 | 1 | 3.0 | 2 | 100% | 0% |
60
+ | bob@acme.example | 5 | 5 | 1.0 | 1 | 0% | 100% |
61
+ | carol@acme.example | 3 | 2 | 1.5 | 3 | 100% | 50% |
62
+ | dan@acme.example | 2 | 1 | 2.0 | 1 | 50% | 0% |
63
+ | eve@acme.example | 3 | 2 | 1.5 | 2 | 100% | 50% |
64
64
  ```
65
65
 
66
66
  **Reading the data:**
@@ -99,13 +99,13 @@ If the input is missing optional fields, columns drop silently to `—`:
99
99
 
100
100
  ```csv
101
101
  timestamp,user,tool
102
- 2026-05-19T09:14:23Z,alice@acme.com,claude-code
103
- 2026-05-19T09:14:55Z,alice@acme.com,claude-code
102
+ 2026-05-19T09:14:23Z,alice@acme.example,claude-code
103
+ 2026-05-19T09:14:55Z,alice@acme.example,claude-code
104
104
  ```
105
105
 
106
106
  ```markdown
107
107
  | User | Invocations | Sessions | Avg / session | Distinct tools | Effectiveness rate | Single-shot rate |
108
- | alice@acme.com | 2 | — | — | 1 | — | — |
108
+ | alice@acme.example | 2 | — | — | 1 | — | — |
109
109
  ```
110
110
 
111
111
  The "Where signals are missing" section then explains *what* couldn't be computed and *why*.
@@ -49,20 +49,20 @@ When a column is omitted, the report includes a *"Where signals are missing"* se
49
49
 
50
50
  ```csv
51
51
  timestamp,user,tool,session_id,output_consumed,consumed_at,outcome
52
- 2026-05-21T09:14:23Z,alice@acme.com,claude-code,sess-001,1,2026-05-21T11:02:00Z,success
53
- 2026-05-21T09:14:55Z,alice@acme.com,claude-code,sess-001,1,2026-05-21T11:02:00Z,success
54
- 2026-05-21T09:18:02Z,alice@acme.com,deck-builder,sess-001,1,2026-05-21T14:30:00Z,success
55
- 2026-05-21T10:01:11Z,bob@acme.com,cursor,sess-014,0,,success
56
- 2026-05-21T10:01:30Z,bob@acme.com,cursor,sess-014,0,,success
57
- 2026-05-21T14:22:08Z,carol@acme.com,claude-code,sess-029,1,2026-05-22T08:11:00Z,success
52
+ 2026-05-21T09:14:23Z,alice@acme.example,claude-code,sess-001,1,2026-05-21T11:02:00Z,success
53
+ 2026-05-21T09:14:55Z,alice@acme.example,claude-code,sess-001,1,2026-05-21T11:02:00Z,success
54
+ 2026-05-21T09:18:02Z,alice@acme.example,deck-builder,sess-001,1,2026-05-21T14:30:00Z,success
55
+ 2026-05-21T10:01:11Z,bob@acme.example,cursor,sess-014,0,,success
56
+ 2026-05-21T10:01:30Z,bob@acme.example,cursor,sess-014,0,,success
57
+ 2026-05-21T14:22:08Z,carol@acme.example,claude-code,sess-029,1,2026-05-22T08:11:00Z,success
58
58
  ```
59
59
 
60
60
  ## Example: JSONL
61
61
 
62
62
  ```jsonl
63
- {"timestamp": "2026-05-21T09:14:23Z", "user": "alice@acme.com", "tool": "claude-code", "session_id": "sess-001", "output_consumed": true, "outcome": "success"}
64
- {"timestamp": "2026-05-21T09:14:55Z", "user": "alice@acme.com", "tool": "claude-code", "session_id": "sess-001", "output_consumed": true, "outcome": "success"}
65
- {"timestamp": "2026-05-21T10:01:11Z", "user": "bob@acme.com", "tool": "cursor", "session_id": "sess-014", "output_consumed": false, "outcome": "success"}
63
+ {"timestamp": "2026-05-21T09:14:23Z", "user": "alice@acme.example", "tool": "claude-code", "session_id": "sess-001", "output_consumed": true, "outcome": "success"}
64
+ {"timestamp": "2026-05-21T09:14:55Z", "user": "alice@acme.example", "tool": "claude-code", "session_id": "sess-001", "output_consumed": true, "outcome": "success"}
65
+ {"timestamp": "2026-05-21T10:01:11Z", "user": "bob@acme.example", "tool": "cursor", "session_id": "sess-014", "output_consumed": false, "outcome": "success"}
66
66
  ```
67
67
 
68
68
  ## A note on the `output_consumed` signal
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "file-to-markdown",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Convert a Word document, PowerPoint deck, Excel workbook, web page, CSV, JSON, text file, email (.eml or Outlook .msg) or PDF into clean Markdown that keeps headings, lists, tables, links, bold and italic, slide titles and speaker notes, and reports everything it dropped (images, charts, comments, formulas, tracked changes), so the content can be read, reviewed or fed to another skill. Use when the user hands over a .docx, .pptx, .xlsx, .html, .csv, .json, .eml, .msg or .pdf and says \"read this\", \"turn this into markdown\", \"extract the text\", or wants it critiqued or briefed.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -26,6 +26,7 @@ import subprocess
26
26
  import sys
27
27
  import tempfile
28
28
  import zipfile
29
+ import zlib
29
30
  from collections import OrderedDict
30
31
  from datetime import datetime, timedelta
31
32
  from html.parser import HTMLParser
@@ -148,12 +149,29 @@ def truthy(el, attr=W + "val"):
148
149
  return v is None or v not in ("0", "false", "off", "none")
149
150
 
150
151
 
152
+ def span_of(value, cap):
153
+ """A cell's column span, from 1 to the format's own limit (Word: 63 columns; HTML: colspan
154
+ 1000), so a damaged or hostile value can't make a row of a billion cells."""
155
+ v = (value or "1").strip()
156
+ return min(int(v), cap) if v.isascii() and v.isdigit() and int(v) > 0 else 1
157
+
158
+
151
159
  def open_zip(path, kind):
152
160
  try:
153
- return zipfile.ZipFile(str(path))
161
+ z = zipfile.ZipFile(str(path))
154
162
  except zipfile.BadZipFile:
163
+ with open(str(path), "rb") as fh:
164
+ if fh.read(8) == b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1":
165
+ # Office saves a password-protected .docx/.pptx/.xlsx as a compound file holding
166
+ # the encrypted package, not as a zip
167
+ sys.exit("error: %s is password-protected (or an old binary Office file). Open it, remove "
168
+ "the password or save it as %s, and rerun." % (path, kind))
155
169
  sys.exit("error: %s is not a valid %s file (not a zip package). If it is an old binary "
156
170
  "Office file, save it as %s first." % (path, kind, kind))
171
+ if any(info.flag_bits & 0x1 for info in z.infolist()):
172
+ sys.exit("error: %s has encrypted parts, which Office never writes; it is damaged, or was "
173
+ "zipped with a password by another tool" % path)
174
+ return z
157
175
 
158
176
 
159
177
  # ---------------------------------------------------------------- docx
@@ -210,6 +228,9 @@ def docx_to_md(path, rep):
210
228
  break
211
229
 
212
230
  body = ET.fromstring(z.read("word/document.xml")).find(W + "body")
231
+ if body is None:
232
+ sys.exit("error: %s has no document body (word/document.xml has no w:body in the WordprocessingML "
233
+ "namespace); it is not a Word document, or it is damaged" % path)
213
234
 
214
235
  def para_segments(p, in_table=False):
215
236
  segs = []
@@ -312,7 +333,7 @@ def docx_to_md(path, rep):
312
333
  return ("decimal", 0)
313
334
  return None
314
335
  fmt = num_fmt.get(num_id, {}).get(ilvl, "bullet")
315
- return (fmt, int(ilvl))
336
+ return (fmt, min(max(int(ilvl), 0), 8)) # Word's list levels are 0 to 8
316
337
 
317
338
  def table_md(tbl):
318
339
  rows = []
@@ -332,7 +353,7 @@ def docx_to_md(path, rep):
332
353
  text = ""
333
354
  row.append(text)
334
355
  if tcpr is not None and tcpr.find(W + "gridSpan") is not None:
335
- row.extend([""] * (int(tcpr.find(W + "gridSpan").get(W + "val", "1")) - 1))
356
+ row.extend([""] * (span_of(tcpr.find(W + "gridSpan").get(W + "val"), 63) - 1))
336
357
  rows.append(row)
337
358
  if len(list(tbl.iter(W + "tbl"))) > 1:
338
359
  rep.notes.append("a nested table was flattened into its parent cell")
@@ -523,7 +544,7 @@ def pptx_to_md(path, rep):
523
544
  if not text:
524
545
  continue
525
546
  ppr = p.find(A + "pPr")
526
- lvl = int(ppr.get("lvl", "0")) if ppr is not None else 0
547
+ lvl = min(max(int(ppr.get("lvl", "0")), 0), 8) if ppr is not None else 0 # PowerPoint's are 0 to 8
527
548
  bullet = is_body
528
549
  if ppr is not None:
529
550
  if ppr.find(A + "buNone") is not None:
@@ -618,12 +639,16 @@ def pptx_to_md(path, rep):
618
639
  BUILTIN_DATE_FMTS = set(range(14, 23)) | {45, 46, 47}
619
640
 
620
641
 
621
- def col_index(ref):
622
- letters = re.match(r"([A-Z]+)", ref).group(1)
642
+ def cell_ref(ref):
643
+ """(row, column) from a cell reference such as B7 or $B$7, counting from 0; None for anything
644
+ else, so a damaged reference skips that cell rather than the sheet."""
645
+ m = re.fullmatch(r"\$?([A-Za-z]{1,3})\$?(\d{1,7})", ref.strip())
646
+ if not m or int(m.group(2)) < 1:
647
+ return None
623
648
  n = 0
624
- for ch in letters:
649
+ for ch in m.group(1).upper():
625
650
  n = n * 26 + (ord(ch) - 64)
626
- return n - 1
651
+ return int(m.group(2)) - 1, n - 1
627
652
 
628
653
 
629
654
  def xlsx_to_md(path, rep, max_rows=0):
@@ -678,8 +703,10 @@ def xlsx_to_md(path, rep, max_rows=0):
678
703
  ref = c.get("r")
679
704
  if not ref:
680
705
  continue
681
- row = int(re.search(r"(\d+)$", ref).group(1)) - 1
682
- col = col_index(ref)
706
+ rc = cell_ref(ref)
707
+ if rc is None:
708
+ continue
709
+ row, col = rc
683
710
  t = c.get("t", "n")
684
711
  v = c.find(S + "v")
685
712
  if c.find(S + "f") is not None:
@@ -717,13 +744,14 @@ def xlsx_to_md(path, rep, max_rows=0):
717
744
  out.append("*(empty sheet)*")
718
745
  out.append("")
719
746
  continue
747
+ # Only the rows and columns that hold a value: one stray cell at ZZZ999999 must not
748
+ # build a grid of a million empty rows.
720
749
  rows_used = sorted({r for r, _ in grid})
721
750
  cols_used = sorted({c for _, c in grid})
722
- c0, c1 = cols_used[0], cols_used[-1]
723
- table = []
724
- for r in range(rows_used[0], rows_used[-1] + 1):
725
- table.append([grid.get((r, c), "") for c in range(c0, c1 + 1)])
726
- table = [r for r in table if any(r)]
751
+ gaps = cols_used[-1] - cols_used[0] + 1 - len(cols_used)
752
+ if gaps:
753
+ rep.notes.append("sheet '%s': %d empty column(s) left out" % (name, gaps))
754
+ table = [[grid.get((r, c), "") for c in cols_used] for r in rows_used]
727
755
  if max_rows and len(table) > max_rows + 1:
728
756
  rep.notes.append("sheet '%s' cut to %d of %d data rows (--max-rows)"
729
757
  % (name, max_rows, len(table) - 1))
@@ -860,8 +888,7 @@ class HTMLToMD(HTMLParser):
860
888
  if not self.table:
861
889
  self.table.append([])
862
890
  self.cell = []
863
- span = int(a.get("colspan") or "1") if (a.get("colspan") or "1").isdigit() else 1
864
- self._span = span
891
+ self._span = span_of(a.get("colspan"), 1000)
865
892
 
866
893
  def handle_endtag(self, tag):
867
894
  if tag in self.SKIP:
@@ -1028,29 +1055,60 @@ def _human_size(n):
1028
1055
  n /= 1024.0
1029
1056
 
1030
1057
 
1058
+ def _part_text(part):
1059
+ """A text part's content. A charset Python doesn't know (a typo, or a vendor's own label) is
1060
+ read as UTF-8 with replacement characters, as html_to_md does, rather than lost."""
1061
+ try:
1062
+ return part.get_content()
1063
+ except LookupError:
1064
+ return (part.get_payload(decode=True) or b"").decode("utf-8", "replace")
1065
+
1066
+
1067
+ def _unescape(text):
1068
+ """The email package keeps undecodable header bytes as surrogates, which can't be written as
1069
+ UTF-8. Read them as UTF-8, with a replacement character for any that aren't."""
1070
+ return text.encode("utf-8", "surrogateescape").decode("utf-8", "replace")
1071
+
1072
+
1073
+ def _header(msg, name):
1074
+ """A header's decoded value, or its raw text when it won't parse: Python 3.9's email package
1075
+ raises on a malformed date or address that later versions keep."""
1076
+ try:
1077
+ return _unescape(str(msg.get(name, "")))
1078
+ except (TypeError, ValueError, IndexError, AttributeError, OverflowError):
1079
+ return _unescape(next((" ".join(str(v).split()) for k, v in msg.raw_items() if k.lower() == name.lower()), ""))
1080
+
1081
+
1031
1082
  def eml_to_md(path, rep):
1032
1083
  """An RFC 5322 message (.eml): the plain-text part when there is one, else the HTML part."""
1033
1084
  import email
1034
1085
  from email import policy
1035
1086
  msg = email.message_from_bytes(path.read_bytes(), policy=policy.default)
1036
- rows = [("From", str(msg.get("From", ""))), ("To", str(msg.get("To", ""))), ("Cc", str(msg.get("Cc", ""))),
1037
- ("Date", str(msg.get("Date", "")))]
1038
- plain = msg.get_body(preferencelist=("plain",))
1039
- html = msg.get_body(preferencelist=("html",))
1087
+ rows = [(h, _header(msg, h)) for h in ("From", "To", "Cc", "Date")]
1088
+ try:
1089
+ plain = msg.get_body(preferencelist=("plain",))
1090
+ html = msg.get_body(preferencelist=("html",))
1091
+ parts = list(msg.iter_attachments())
1092
+ except (AttributeError, TypeError, KeyError):
1093
+ # Python 3.9 fails inside get_body on a multipart message whose boundary never appears.
1094
+ # Read it as one text body, which is what the raw message holds.
1095
+ raw = msg.get_payload()
1096
+ rep.notes.append("the message's MIME parts could not be separated, so its body is the raw text")
1097
+ return _email_md(_header(msg, "Subject"), rows, _email_text(_unescape(raw) if isinstance(raw, str) else ""), [], rep)
1040
1098
  if plain is not None:
1041
- body = _email_text(plain.get_content())
1099
+ body = _email_text(_part_text(plain))
1042
1100
  if html is not None:
1043
1101
  rep.drop("HTML alternative (the plain-text part was used)", 1)
1044
1102
  elif html is not None:
1045
- body = _html_text_to_md(html.get_content(), rep)
1103
+ body = _html_text_to_md(_part_text(html), rep)
1046
1104
  else:
1047
1105
  body = ""
1048
1106
  attachments = []
1049
- for part in msg.iter_attachments():
1107
+ for part in parts:
1050
1108
  payload = part.get_payload(decode=True)
1051
1109
  name = part.get_filename() or ("forwarded message" if part.get_content_type() == "message/rfc822" else None)
1052
1110
  attachments.append((name, len(payload) if payload is not None else None))
1053
- return _email_md(str(msg.get("Subject", "")), rows, body, attachments, rep)
1111
+ return _email_md(_header(msg, "Subject"), rows, body, attachments, rep)
1054
1112
 
1055
1113
 
1056
1114
  class _CFB:
@@ -1073,14 +1131,20 @@ class _CFB:
1073
1131
  minifat_start, nminifat, difat_start, ndifat = struct.unpack_from("<IIII", data, 60)
1074
1132
  difat = list(struct.unpack_from("<109I", data, 76))
1075
1133
  per = self.ss // 4
1076
- s, guard = difat_start, 0
1077
- while ndifat and s < 0xFFFFFFFA and guard < 10000:
1134
+ # A FAT never needs more sectors than it takes to map the file, and lists each one once:
1135
+ # a repeated or looping list would otherwise build a table far larger than the file.
1136
+ nsect = len(data) // self.ss - 1
1137
+ need = -(-nsect // per)
1138
+ s, seen = difat_start, set()
1139
+ while ndifat and s < 0xFFFFFFFA and s not in seen and len(seen) < need:
1140
+ seen.add(s)
1078
1141
  vals = struct.unpack("<%dI" % per, self._sector(s))
1079
1142
  difat += vals[:-1]
1080
- s, guard, ndifat = vals[-1], guard + 1, ndifat - 1
1081
- self.fat = []
1082
- for fs in difat[:nfat]:
1083
- if fs < 0xFFFFFFFA:
1143
+ s, ndifat = vals[-1], ndifat - 1
1144
+ self.fat, used = [], set()
1145
+ for fs in difat[:min(nfat, need)]:
1146
+ if fs < 0xFFFFFFFA and fs not in used:
1147
+ used.add(fs)
1084
1148
  self.fat += struct.unpack("<%dI" % per, self._sector(fs))
1085
1149
  raw = self._chain(dir_start)
1086
1150
  self.entries = []
@@ -1108,16 +1172,17 @@ class _CFB:
1108
1172
  return self.d[off:off + self.ss]
1109
1173
 
1110
1174
  def _chain(self, start, mini=False):
1111
- fat, out, n, seen = (self.minifat if mini else self.fat), [], start, 0
1175
+ fat, out, n, seen = (self.minifat if mini else self.fat), [], start, set()
1112
1176
  while n < 0xFFFFFFFA:
1113
- if n >= len(fat) or seen > 1000000:
1177
+ if n >= len(fat) or n in seen: # off the table, or a loop: each sector is read once
1114
1178
  raise ValueError("compound file has a broken sector chain")
1179
+ seen.add(n)
1115
1180
  if mini:
1116
1181
  off = n * self.mss
1117
1182
  out.append(self.ministream[off:off + self.mss])
1118
1183
  else:
1119
1184
  out.append(self._sector(n))
1120
- n, seen = fat[n], seen + 1
1185
+ n = fat[n]
1121
1186
  return b"".join(out)
1122
1187
 
1123
1188
  def children(self, idx):
@@ -1171,8 +1236,11 @@ def _msg_fixed(cfb, idx, header):
1171
1236
  ptype, pid = tag & 0xFFFF, "%04X" % (tag >> 16)
1172
1237
  if ptype == 0x0040:
1173
1238
  ft, = struct.unpack_from("<Q", data, off + 8)
1174
- if ft:
1175
- out[pid] = _dt(1601, 1, 1) + timedelta(microseconds=ft // 10)
1239
+ try:
1240
+ if ft:
1241
+ out[pid] = _dt(1601, 1, 1) + timedelta(microseconds=ft // 10)
1242
+ except OverflowError:
1243
+ pass # a time past the year 9999 is a placeholder or damage; the message has no date
1176
1244
  elif ptype == 0x0003:
1177
1245
  out[pid] = struct.unpack_from("<i", data, off + 8)[0]
1178
1246
  return out
@@ -1679,6 +1747,9 @@ HANDLERS = {
1679
1747
  ".html": html_to_md, ".htm": html_to_md, ".csv": csv_to_md, ".tsv": csv_to_md,
1680
1748
  ".json": json_to_md, ".pdf": pdf_to_md, ".eml": eml_to_md, ".msg": msg_to_md,
1681
1749
  }
1750
+ # What a damaged or truncated file raises from the zip, XML, compound-file and email layers.
1751
+ DAMAGED = (zipfile.BadZipFile, zipfile.LargeZipFile, zlib.error, EOFError, ET.ParseError, ValueError, LookupError,
1752
+ KeyError, IndexError, NotImplementedError, OSError)
1682
1753
  LEGACY = {".doc": ".docx", ".ppt": ".pptx", ".xls": ".xlsx", ".rtf": ".docx", ".odt": ".docx",
1683
1754
  ".pages": ".docx", ".key": ".pptx", ".numbers": ".xlsx"}
1684
1755
 
@@ -1711,8 +1782,15 @@ def main(argv=None):
1711
1782
  md += "\n"
1712
1783
  elif ext in HANDLERS:
1713
1784
  fn = HANDLERS[ext]
1714
- md = (fn(path, rep, a.max_rows) if fn is xlsx_to_md else
1715
- fn(path, rep, ocr=not a.no_ocr, ocr_lang=a.ocr_lang) if fn is pdf_to_md else fn(path, rep))
1785
+ try:
1786
+ md = (fn(path, rep, a.max_rows) if fn is xlsx_to_md else
1787
+ fn(path, rep, ocr=not a.no_ocr, ocr_lang=a.ocr_lang) if fn is pdf_to_md else fn(path, rep))
1788
+ except DAMAGED as e:
1789
+ if ext not in (".docx", ".pptx", ".xlsx", ".xlsm", ".msg", ".eml"):
1790
+ raise
1791
+ # A file that opens in its own app is a bug here; one that doesn't is damaged.
1792
+ sys.exit("error: %s looks damaged or truncated, so it could not be read as %s (%s: %s). If it "
1793
+ "opens normally in its own app, please report it." % (path, ext, type(e).__name__, e))
1716
1794
  else:
1717
1795
  sys.exit("error: unsupported file type %r. Supported: %s, .txt, .md"
1718
1796
  % (ext, ", ".join(sorted(HANDLERS))))
@@ -1,4 +1,4 @@
1
- FROM python:3.9-slim
1
+ FROM python:3.9-slim@sha256:2d97f6910b16bd338d3060f261f53f144965f755599aab1acda1e13cf1731b1b
2
2
 
3
3
  WORKDIR /app
4
4
  COPY requirements.txt .
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollout-plan",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Plan the progressive release of a code change behind a feature flag or canary — the exposure ladder (internal, beta cohort, 1%, 5%, 25%, 50%, 100%, or what the traffic supports), and for each step who is exposed, decision and guardrail metrics with a baseline and a threshold against a control cohort, the minimum traffic and time before the numbers mean anything, a promote / hold / roll back rule set in advance, who watches and what aborts automatically; then what turning the flag off does not undo, and who removes the flag by when. Use when the user asks for a rollout plan, canary plan, ramp schedule, progressive delivery or percentage rollout, \"how long should the canary run\", \"when do we go from 5% to 25%\", or how to turn on a flagged feature safely.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": { "npm": [], "pip": [] },
@@ -165,8 +165,8 @@ def main():
165
165
  fail("--traffic must be a finite number greater than zero")
166
166
  if not 0 < args.alpha < 1 or not 0 < args.power < 1:
167
167
  fail("--alpha and --power must be between 0 and 1")
168
- if args.max_hours <= 0:
169
- fail("--max-hours must be greater than zero")
168
+ if not math.isfinite(args.max_hours) or args.max_hours <= 0:
169
+ fail("--max-hours must be a finite number greater than zero")
170
170
  control = None
171
171
  if args.control_pct is not None:
172
172
  control = parse_rate(args.control_pct.rstrip("%") + "%", "--control-pct")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypothesis-comparison",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Compare rival explanations for an observation with analysis of competing hypotheses (ACH) — list the hypotheses and the evidence, build a consistency matrix (consistent / inconsistent / neutral), rank hypotheses by how little evidence contradicts them rather than how much supports them, name the diagnostic evidence and the linchpins, and say which next observation would tell the leaders apart. Use when the user asks \"why did X happen\", \"which explanation fits the evidence\", \"what's really causing this\", wants to stop arguing over a favourite theory, or says \"compare these hypotheses\", \"competing explanations\", \"ACH\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -34,6 +34,7 @@ Exit codes: 0 report written, 2 bad input.
34
34
  import argparse
35
35
  import csv
36
36
  import io
37
+ import math
37
38
  import sys
38
39
  from pathlib import Path
39
40
 
@@ -71,28 +72,31 @@ def load(path):
71
72
  fail("the CSV needs at least two hypothesis columns; ACH compares rivals")
72
73
  rows = []
73
74
  for i, raw in enumerate(reader, start=1):
75
+ line = reader.line_num # the file's line, which counts the blank lines csv skips
74
76
  if None in raw:
75
77
  fail("row {}: more cells than the header has columns; put double quotes around any "
76
- "cell that contains a comma".format(i + 1))
78
+ "cell that contains a comma".format(line))
77
79
  row = {(k or "").strip(): (v or "").strip() for k, v in raw.items()}
78
80
  lrow = {k.lower(): v for k, v in row.items()}
79
81
  ev = lrow.get("evidence", "")
80
82
  if not ev:
81
- fail("row {}: the evidence cell is empty".format(i + 1))
83
+ fail("row {}: the evidence cell is empty".format(line))
82
84
  rid = lrow.get("id") or "E{}".format(i)
83
85
  w_raw = lrow.get("weight") or "1"
84
86
  try:
85
87
  w = float(w_raw)
86
88
  except ValueError:
87
- fail("row {} ({}): weight {!r} is not a number".format(i + 1, rid, w_raw))
89
+ fail("row {} ({}): weight {!r} is not a number".format(line, rid, w_raw))
90
+ if not math.isfinite(w):
91
+ fail("row {} ({}): weight {!r} is not a finite number".format(line, rid, w_raw))
88
92
  if w <= 0:
89
- fail("row {} ({}): weight must be greater than 0".format(i + 1, rid))
93
+ fail("row {} ({}): weight must be greater than 0".format(line, rid))
90
94
  ratings = {}
91
95
  for h in hyps:
92
96
  r = row.get(h, "").upper()
93
97
  if r not in RATINGS:
94
98
  fail("row {} ({}), column {!r}: rating {!r} is not one of CC, C, N, NA, I, II".format(
95
- i + 1, rid, h, row.get(h, "")))
99
+ line, rid, h, row.get(h, "")))
96
100
  ratings[h] = r
97
101
  rows.append({"id": rid, "evidence": ev, "weight": w, "ratings": ratings})
98
102
  if not rows:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-metrics-report",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Produce a delivery metrics report for one service from deployment, incident and pull-request exports — the DORA metrics (deployment frequency, change lead time, change fail rate, failed deployment recovery time) plus flow metrics (WIP, cycle time p50/p85, throughput), computed by a script with per-week trends and explicit data gaps, then interpreted for what changed and why. Use when the user wants DORA metrics, says \"how are we doing on the four keys\", \"what's our change failure rate\", \"compute lead time and deploy frequency from this CSV\", or \"build our delivery metrics report\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
 
26
26
  import argparse
27
27
  import csv
28
+ import io
28
29
  import json
29
30
  import math
30
31
  import re
@@ -96,7 +97,12 @@ def parse_bool(raw, where: str):
96
97
  def read_csv(path: Path, required: list, label: str) -> list:
97
98
  if not path.exists():
98
99
  raise InputError("%s file not found: %s" % (label, path))
99
- with path.open(newline="", encoding="utf-8-sig") as f:
100
+ try:
101
+ text = path.read_text(encoding="utf-8-sig")
102
+ except UnicodeDecodeError:
103
+ raise InputError("%s file %s is not UTF-8 text; save it as UTF-8 (in Excel: CSV UTF-8) and retry"
104
+ % (label, path))
105
+ with io.StringIO(text, newline="") as f:
100
106
  reader = csv.DictReader(f)
101
107
  if not reader.fieldnames:
102
108
  raise InputError("%s file %s is empty or has no header row" % (label, path))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "team-status-report",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Write a weekly team status report from tracker data (Jira, Linear or GitHub Projects, via CSV, JSON or an MCP tool) — shipped, in progress, blocked with the blocker and owner, slipped against plan, risks and asks — with every number computed from the data by a stdlib script, never estimated, and a RAG status that states the rule that set it. Use when the user wants a weekly status update, sprint report or stakeholder update from the tracker, or says \"write this week's status report\", \"are we on track?\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -305,8 +305,11 @@ def main(argv=None) -> int:
305
305
  if args.plan_file:
306
306
  if not args.plan_file.exists():
307
307
  die("--plan-file {} does not exist".format(args.plan_file))
308
- keys = [ln.strip() for ln in args.plan_file.read_text(encoding="utf-8").splitlines()
309
- if ln.strip() and not ln.startswith("#")]
308
+ try:
309
+ plan_text = args.plan_file.read_text(encoding="utf-8-sig")
310
+ except UnicodeDecodeError:
311
+ die("--plan-file {} is not UTF-8 text; save it as UTF-8 and retry".format(args.plan_file))
312
+ keys = [ln.strip() for ln in plan_text.splitlines() if ln.strip() and not ln.startswith("#")]
310
313
  by_key = {it["key"]: it for it in items}
311
314
  missing = [k for k in keys if k not in by_key]
312
315
  if missing: