skilldrop-cli 0.16.46 → 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/README.md +1 -1
- package/package.json +1 -1
- package/packs/ai-engineering/skills/ai-usage-report/examples/weekly-engineering-team.md +24 -24
- package/packs/ai-engineering/skills/ai-usage-report/templates/usage-event-schema.md +9 -9
- package/packs/converters/skills/file-to-markdown/manifest.json +1 -1
- package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +116 -38
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/Dockerfile +1 -1
- package/packs/dev-team/skills/rollout-plan/manifest.json +1 -1
- package/packs/dev-team/skills/rollout-plan/scripts/canary_math.py +2 -2
- package/packs/research/skills/hypothesis-comparison/manifest.json +1 -1
- package/packs/research/skills/hypothesis-comparison/scripts/ach_matrix.py +9 -5
- package/packs/sre-oncall/skills/delivery-metrics-report/manifest.json +1 -1
- package/packs/sre-oncall/skills/delivery-metrics-report/scripts/delivery_metrics.py +7 -1
- package/packs/trackers/skills/team-status-report/manifest.json +1 -1
- package/packs/trackers/skills/team-status-report/scripts/status_counts.py +5 -2
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Every skill runs as a real agent session twice: once with the skill, once withou
|
|
|
31
31
|
- **Two blind judges preferred the skill's answer in 61% of pairs** (95% interval 55–68%). Each judge saw only the request and both answers, in shuffled order.
|
|
32
32
|
- **4 skills that didn't beat the plain agent were [retired](https://sananthanarayan.github.io/skilldrop/evals/#retired)**, and they're listed. Each new skill has to win on held-out evals before it ships.
|
|
33
33
|
|
|
34
|
-
[Get started](https://sananthanarayan.github.io/skilldrop/start/) · [Browse the packs](https://sananthanarayan.github.io/skilldrop/packs/) · [Search all skills](https://sananthanarayan.github.io/skilldrop/catalogue/) · [Docs](https://sananthanarayan.github.io/skilldrop/docs/) · [Contribute](CONTRIBUTING.md)
|
|
34
|
+
[Get started](https://sananthanarayan.github.io/skilldrop/start/) · [Browse the packs](https://sananthanarayan.github.io/skilldrop/packs/) · [Search all skills](https://sananthanarayan.github.io/skilldrop/catalogue/) · [Docs](https://sananthanarayan.github.io/skilldrop/docs/) · [What stays stable in 1.x](guides/reference/versioning-and-stability.md) · [Contribute](CONTRIBUTING.md)
|
|
35
35
|
|
|
36
36
|
## Start in one command
|
|
37
37
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skilldrop-cli",
|
|
3
|
-
"version": "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.
|
|
10
|
-
2026-05-19T09:14:55Z,alice@acme.
|
|
11
|
-
2026-05-19T09:18:02Z,alice@acme.
|
|
12
|
-
2026-05-19T10:01:11Z,bob@acme.
|
|
13
|
-
2026-05-19T10:01:30Z,bob@acme.
|
|
14
|
-
2026-05-19T10:02:11Z,bob@acme.
|
|
15
|
-
2026-05-19T10:03:11Z,bob@acme.
|
|
16
|
-
2026-05-19T10:04:11Z,bob@acme.
|
|
17
|
-
2026-05-20T14:22:08Z,carol@acme.
|
|
18
|
-
2026-05-20T15:22:08Z,carol@acme.
|
|
19
|
-
2026-05-20T16:22:08Z,carol@acme.
|
|
20
|
-
2026-05-19T11:14:23Z,dan@acme.
|
|
21
|
-
2026-05-19T11:15:23Z,dan@acme.
|
|
22
|
-
2026-05-20T08:14:23Z,eve@acme.
|
|
23
|
-
2026-05-20T08:15:23Z,eve@acme.
|
|
24
|
-
2026-05-20T08:16:23Z,eve@acme.
|
|
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.
|
|
60
|
-
| bob@acme.
|
|
61
|
-
| carol@acme.
|
|
62
|
-
| dan@acme.
|
|
63
|
-
| eve@acme.
|
|
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.
|
|
103
|
-
2026-05-19T09:14:55Z,alice@acme.
|
|
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.
|
|
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.
|
|
53
|
-
2026-05-21T09:14:55Z,alice@acme.
|
|
54
|
-
2026-05-21T09:18:02Z,alice@acme.
|
|
55
|
-
2026-05-21T10:01:11Z,bob@acme.
|
|
56
|
-
2026-05-21T10:01:30Z,bob@acme.
|
|
57
|
-
2026-05-21T14:22:08Z,carol@acme.
|
|
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.
|
|
64
|
-
{"timestamp": "2026-05-21T09:14:55Z", "user": "alice@acme.
|
|
65
|
-
{"timestamp": "2026-05-21T10:01:11Z", "user": "bob@acme.
|
|
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.
|
|
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
|
-
|
|
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([""] * (
|
|
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
|
|
622
|
-
|
|
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
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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 = [(
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
|
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
|
|
1103
|
+
body = _html_text_to_md(_part_text(html), rep)
|
|
1046
1104
|
else:
|
|
1047
1105
|
body = ""
|
|
1048
1106
|
attachments = []
|
|
1049
|
-
for part in
|
|
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(
|
|
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
|
-
|
|
1077
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
1175
|
-
|
|
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
|
-
|
|
1715
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rollout-plan",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
"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
|
-
|
|
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
|
+
"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
|
-
|
|
309
|
-
|
|
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:
|