Compare commits

..
7 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5 2399ce68e5 chore(release): plugin.json version 0.20.0
CI / Check / Test (push) Successful in 9s
CI / Tag release (push) Successful in 4s
New skill (document-to-markdown) is a minor bump; the gate asserts
manifest/tag parity, so this lands with the tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 19:44:28 +02:00
mathiasandClaude Opus 5 46cbfc5967 feat(document-to-markdown): read the formats Read cannot open, locally
CI / Check / Test (push) Successful in 9s
CI / Tag release (push) Successful in 2s
Read handles PDF, images and notebooks natively; it does not open .docx,
.xlsx, .pptx, .epub, .msg or .zip. One uvx command does, with no install step
and no service.

Deliberately does NOT call docmark, which is the counter-intuitive part.
Measured 2026-09-24 on the same .docx, tables included: local markitdown and
the docmark service return byte-identical Markdown. Conversion of a
text-bearing document involves no LLM at all — the client is used only to
describe images. A network hop, a bearer token and an ingress for the same
bytes is not a trade worth making, and docmark is unreachable from a session
on koala anyway (ClusterIP, NetworkPolicy admits supervisor and dispatch only).

The boundary is measured, not asserted. The light extras register 18
converters; docmark registers 22. The difference is exactly DocxWithOCR,
PdfWithOCR, PptxWithOCR and XlsxWithOCR — the vision path, which is the one
case that must go through docmark because its sovereignty gate is the estate's
only audited route from an image to a model.

Two hazards the skill names because both bite silently:

  - markitdown[all] pulls az-doc-intel and az-content-understanding, Azure
    cloud converters. The extras list avoids having them on the path.
  - A blank template and a scanned PDF with no text layer both convert to zero
    characters and exit 0. Check the byte count; an empty success is the
    failure mode, and a confident summary of an unread document is the worst
    output this skill could produce.

Refs mathias/docmark#3

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 19:44:10 +02:00
mathias cf3abafc66 chore(release): plugin.json version 0.19.6
Skip-Release: true
2026-09-11 09:11:36 +00:00
mathias 6838baa071 fix(ci): gate the release-bump commit — nothing has ever tested it
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 1s
Found from the other end: the koala cluster's CIHeadUncovered{repo=skills}
alert had been firing for 85 hours, and it is a true positive.

    mathias/skills main HEAD   f031c2b
    workflow runs for that sha  0

Not a one-off. The previous release-bump commit has the same property:

    6cc224f  chore(release): plugin.json version 0.19.4  ->  0 runs
    f031c2b  chore(release): plugin.json version 0.19.5  ->  0 runs

The `tag` job pushes the manifest bump itself with secrets.GITEA_TOKEN, and a
push authenticated with the Actions token does not trigger workflows -- that is
deliberate platform behaviour to stop CI recursing. The `Skip-Release: true`
trailer stops the TAGGER recursing and works; nothing stopped the whole run,
`gate` included, from never firing on that commit.

So after every release, main's HEAD is a commit no CI has seen, until an
unrelated human push moves it. CIHeadUncovered therefore cannot clear on its
own -- and an alert that can only be cleared by doing something unrelated is
worse than a noisy one, because it teaches the reader that red is the normal
state of that rule.

The commit is not cosmetic either. plugin.json's version is what Claude Code
gates plugin updates on, `task check` asserts it is not behind the latest tag,
and the sed-and-retry logic that writes it already shipped a mismatched
manifest once -- v0.10.0 tagged with the manifest still reading 0.9.0, per the
comment above that very loop.

Fix: run `task check` on the bump commit before pushing it. The commit is
produced in that step, so it can be validated in that step -- one check instead
of a second full CI run, and no recursion risk to reintroduce.

Considered and rejected: re-triggering CI with a different credential after the
push (costs a full run per release and brings back what the token behaviour
exists to prevent), and teaching CIHeadUncovered to ignore manifest-only
commits (narrowing the measure to fit the system, and it would hide a real gap
too).

The assertion in tests/ci_gate_test.sh had to be anchored with ^[^#]*: the tag
job's own prose says "`task check` asserts the manifest is not behind the
latest tag", so a bare grep matched the comment explaining the problem and
reported the problem fixed.

Closes #46.
2026-09-11 11:11:16 +02:00
mathias f031c2b77d chore(release): plugin.json version 0.19.5
Skip-Release: true
2026-09-08 19:35:09 +00:00
mathiasandClaude Opus 5 1328dcd8f3 feat(doctor): report golangci-lint built with a Go older than GOROOT
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 1s
Second recurrence of the same host fault (infra#375; first hit
openbanking-api 2026-09-03, then cobalt-dingo 2026-09-08). golangci-lint
embeds the go/types export-data reader from the Go it was BUILT with, so a
newer GOROOT makes it unable to decode the stdlib:

  panic: file requires newer Go version go1.27 (application built with go1.26)

An earlier form printed a typecheck error naming the user's own source file,
which sends people to debug code that is fine. It cost time in two repos before
anyone recognised it as a host fault rather than a repo fault.

CI never sees it — the runner image has matching versions — so it breaks
exactly the local `task check` that AGENT.md requires before every push, and
nothing else. The fix was always one reinstall; the missing thing was the
signal, which is what doctor is for.

Fails open by design: an unparseable version, or no Go at all, is a normal host
and not a fault doctor may assert. Version comparison is major.minor in POSIX
sh rather than sort -V, since this runs on macOS too and the export-data format
is minor-versioned.

Also makes the suite hermetic. The check reads the real host, so on a machine
whose golangci-lint is genuinely stale every unrelated fixture started failing.
The harness now prepends a matching fake pair for the whole file, and only the
four cases that opt in exercise the check.

Verified against koala, which is currently in exactly this state:

  FAIL toolchain: golangci-lint built with go1.26.1 but GOROOT is go1.27.0
       — lint panics on every Go repo; reinstall it under the current
       toolchain (infra#375)

doctor: 25 passed, 0 failed. Full suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:34:44 +02:00
mathias 6cc224f151 chore(release): plugin.json version 0.19.4
Skip-Release: true
2026-09-07 20:34:58 +00:00
7 changed files with 301 additions and 1 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "skills",
"description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.",
"version": "0.19.3",
"version": "0.20.0",
"author": {
"name": "Mathias",
"url": "https://git.d-ma.be/mathias"
+27
View File
@@ -133,6 +133,33 @@ jobs:
git add "$manifest"
# The trailer below stops this push from re-triggering the tagger.
git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true"
# skills#46. This commit is pushed with secrets.GITEA_TOKEN, and a
# push authenticated with the Actions token does NOT trigger
# workflows -- deliberate platform behaviour to stop CI recursing.
# `Skip-Release: true` stops the tagger recursing; nothing stopped
# the whole run, `gate` included, from never firing on this commit.
#
# So every release left main's HEAD with zero CI runs. Measured:
# 6cc224f (0.19.4) and f031c2b (0.19.5) each have none, and the
# koala cluster's CIHeadUncovered{repo=skills} had been firing on it
# for 85 hours -- an alert that can only be cleared by an unrelated
# human push.
#
# The commit is not cosmetic: plugin.json's version is what Claude
# Code gates plugin updates on, `task check` asserts it is not
# behind the latest tag, and the sed-and-retry logic above already
# shipped a mismatched manifest once (v0.10.0 reading 0.9.0).
#
# Gating it HERE rather than re-triggering CI afterwards: the commit
# is produced here, so it can be validated here, at the cost of one
# `task check` instead of a second full run -- and with no recursion
# risk to reintroduce.
if ! task check; then
echo "::error::the release-bump commit fails task check -- not pushing it" >&2
exit 1
fi
if git push origin HEAD:main; then
echo "bumped $manifest to $version"
exit 0
+1
View File
@@ -17,6 +17,7 @@ the SKILL.md, not the table.
| `cognitive-load` | Cognitive complexity analysis (CLI score) | Identifying hotspots, before refactoring | claude-code, library |
| `debug` | Hypothesis-first debugging discipline | Any test failure or bug whose cause is not immediately obvious — or a bug report with no failure text, which this skill refuses rather than guesses at | claude-code, library |
| `discovery-framing` | Problem-before-solution discovery/framing method | Framing/validating a problem before any solution; opportunity sizing | claude-code, library |
| `document-to-markdown` | Read .docx/.xlsx/.pptx/.epub/.msg and other formats the Read tool cannot open | You need the contents of a binary document — Word, Excel, PowerPoint, EPUB, Outlook .msg, a zip of documents — and Read returns nothing useful | claude-code, library |
| `dream` | Reflective consolidation pass over an agent's memory directory | Pruning/merging memory files, rebuilding a memory index | claude-code, library, chat |
| `experiment-spec` | Rigorous experiment spec — falsifiable hypothesis, quantitative acceptance, null-result protocol | Scoping a research/ML phase instead of a feature | claude-code, library |
| `feature-spec` | Implementation-level feature spec (tighter than spec-driven-dev) | Scoping one feature inside an already-specced project | claude-code, library |
+38
View File
@@ -327,6 +327,44 @@ if [ "$ONLINE" -eq 1 ]; then
fi
fi
# --- toolchain: golangci-lint must be built with Go >= GOROOT ---------------
# infra#375, second recurrence. golangci-lint embeds the go/types export-data
# reader from the Go it was BUILT with. When GOROOT is newer, that reader
# cannot decode the stdlib and the linter dies:
#
# panic: file requires newer Go version go1.27 (application built with go1.26)
#
# An earlier form printed a typecheck error naming the user's own file, which
# sends people to debug code that is fine. It cost time in two repos before
# anyone recognised it as a host fault rather than a repo fault.
#
# It is invisible to CI -- the runner image has matching versions -- so it
# breaks exactly the local `task check` that AGENT.md requires before a push,
# and nothing else. That is why it belongs in doctor: the signal was missing,
# not the fix.
#
# Fails open on anything unparseable. A host with no Go toolchain is a normal
# host, not a broken one, and doctor runs in a SessionStart hook everywhere.
ver_lt() {
# 0 when $1 < $2 on major.minor. Deliberately not sort -V: this is POSIX sh
# that also runs on macOS, and the export-data format is minor-versioned, so
# patch level does not enter into it.
_a=${1%%.*}; _ar=${1#*.}; _am=${_ar%%.*}
_b=${2%%.*}; _br=${2#*.}; _bm=${_br%%.*}
case "$_a$_am$_b$_bm" in *[!0-9]*) return 1 ;; esac
[ "$_a" -lt "$_b" ] && return 0
[ "$_a" -gt "$_b" ] && return 1
[ "$_am" -lt "$_bm" ]
}
if command -v golangci-lint >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then
lint_go=$(golangci-lint --version 2>/dev/null | sed -n 's/.*built with go\([0-9][0-9.]*\).*/\1/p' | head -1)
root_go=$(go version 2>/dev/null | sed -n 's/.*go version go\([0-9][0-9.]*\).*/\1/p' | head -1)
if [ -n "$lint_go" ] && [ -n "$root_go" ] && ver_lt "$lint_go" "$root_go"; then
add FAIL toolchain "golangci-lint built with go$lint_go but GOROOT is go$root_go — lint panics on every Go repo; reinstall it under the current toolchain (infra#375)"
fi
fi
# --- output -----------------------------------------------------------------
if [ "$JSON" -eq 1 ]; then
printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \
+126
View File
@@ -0,0 +1,126 @@
---
name: document-to-markdown
surfaces: [claude-code, library]
summary: "Read .docx/.xlsx/.pptx/.epub/.msg and other formats the Read tool cannot open"
use_when: "You need the contents of a binary document — Word, Excel, PowerPoint, EPUB, Outlook .msg, a zip of documents — and Read returns nothing useful"
description: Convert a binary document to Markdown locally with markitdown. No network, no LLM, safe for confidential material. Says when to use the docmark service instead.
---
# Document → Markdown
## Overview
`Read` opens PDFs, images and notebooks natively. It does **not** open `.docx`,
`.xlsx`, `.pptx`, `.epub`, `.msg` or `.zip`. This skill converts those locally.
One command, no install step, no service:
```bash
uvx --from 'markitdown[docx,pdf,pptx,xls,xlsx,outlook]' markitdown FILE > /tmp/out.md
```
First run downloads 38 packages (~10s); afterwards it is cached and instant.
## When to use this
You have a file on disk whose contents you need. That is the whole trigger.
## When NOT to use this — use `docmark` instead
Three cases, and only three:
1. **A scanned PDF or an image whose content is pixels, not text.** Extracting
that needs a vision model. The estate's only audited path from an image to a
model is `docmark`, whose `sovereignty.py` constructs an LLM client *only*
when `DOCMARK_VISION_MODEL` matches `koala/*` or `iguana/*`, and silently
degrades to metadata-only otherwise. `tests/test_sovereignty.py` is the
load-bearing guard. Do not reach for a cloud vision model directly; that is
the boundary the gate exists to hold.
2. **You are writing an in-cluster service** (something in `dispatch`,
`ingestion`, `cobalt-dingo`). Call `docmark` over MCP rather than bundling a
converter into each service. Its NetworkPolicy already admits `supervisor`
and `dispatch`.
3. **The file is larger than ~25 MiB** or you want the input/output caps
enforced centrally.
For a text-bearing document, the service returns **byte-identical output** to
the local command — measured 2026-09-24 on the same `.docx`, tables included.
A network hop, a bearer token and an ingress for the same bytes is not a
trade worth making.
## Confidential material
**Conversion is local and involves no LLM.** `markitdown` uses an LLM client
only to describe images; with none configured it does not call anything. No
bytes leave the machine.
That makes this safe for client material, unlike anything that routes through a
model. Two cautions:
- **Do not install `markitdown[all]`.** It pulls `az-doc-intel` and
`az-content-understanding` — Azure cloud document-intelligence converters.
They need explicit endpoint configuration to activate, but the extras list
above avoids having them on the path at all.
- The output is a file like any other. If the source was confidential, so is
`/tmp/out.md` — and the usual rule applies: it must not reach an open-egress
sink (ADR-0014).
## Verify the conversion, do not assume it
The failure mode is **a successful exit with empty output**. A `.docx` template
with no body content converts to zero characters and exits 0; so does a scanned
PDF with no text layer. Both look like success.
```bash
uvx --from 'markitdown[docx,pdf,pptx,xls,xlsx,outlook]' markitdown FILE > /tmp/out.md
wc -c /tmp/out.md
```
If the byte count is 0 or implausibly small for the document's size, the
converter found no text layer — that is case 1 above, and it needs `docmark`
with a vision model, not a retry.
## What it converts
18 converters with the extras above:
```
Audio, BingSerp, Csv, Docx, Epub, Html, Image, Ipynb, OutlookMsg, Pdf,
PlainText, Pptx, Rss, Wikipedia, Xls, Xlsx, YouTube, Zip
```
`docmark` registers 22 — the same set plus `DocxWithOCR`, `PdfWithOCR`,
`PptxWithOCR`, `XlsxWithOCR`. **Those four are the entire difference**, and
they are exactly the vision path from case 1. Everything else is identical code
running in a different place.
## Reading the output
Convert to a file and `Read` it, rather than piping into the transcript —
a 200-page document produces a lot of Markdown and most of it will not be
what you needed. Convert, then grep or read the section you want.
## Provenance
`docmark` ADR-0013 (`mathias/infra docs/decisions/`) owns the service; this
skill is the local path. The split was decided in `mathias/docmark#3` on
measurement, not preference: three of docmark's four plausible consumers are
better served without it, and the fourth — vision under a sovereignty gate — is
why it exists.
## Escalation
Local, read-only, single command. Nothing here is handed back mid-task.
Stop and hand back when:
- **The output is empty or near-empty.** Do not retry, and do not guess at the
contents. Say which file, what the byte count was, and that it needs a vision
path — then either use `docmark` with a local vision model, or ask. A
confident summary of a document you could not read is the worst possible
output of this skill.
- **The document is client material and the task would send its contents
anywhere** — a commit message, an issue, a PR body, a cloud model. That is
ADR-0014's refusal, and it is not this skill's call to make.
- **The file is encrypted or password-protected.** markitdown will fail; asking
for the password is a human's decision, not a retry.
+26
View File
@@ -53,5 +53,31 @@ printf '%s' "$tag_job" | grep -qE '^ +needs: *gate' \
&& ok 'ungated release.yml removed' \
|| no 'ungated release.yml removed'
# skills#46. The manifest-bump commit is created and pushed by the `tag` job
# using secrets.GITEA_TOKEN, and a push authenticated with the Actions token
# does not trigger workflows -- deliberate platform behaviour to stop CI
# recursing. So the ONE commit in this repo that no CI run has ever seen is the
# one the release job writes, every release:
#
# 6cc224f chore(release): plugin.json version 0.19.4 -> 0 runs
# f031c2b chore(release): plugin.json version 0.19.5 -> 0 runs
#
# The koala cluster's CIHeadUncovered{repo=skills} alert had been firing on it
# for 85 hours when this was found, and it cannot clear on its own -- only an
# unrelated human push moves main's HEAD to a tested commit.
#
# The commit is not nothing: plugin.json's version is what Claude Code gates
# plugin updates on, `task check` asserts it is not behind the latest tag, and
# the sed-and-retry logic that writes it already shipped a mismatched manifest
# once (v0.10.0 with the manifest reading 0.9.0).
#
# Anchored past comments: the tag job's prose already SAYS "`task check`
# asserts the manifest is not behind the latest tag", and a bare grep matches
# that and passes. A grep for a concept matches the rationale, and the more
# carefully a thing is documented the more likely that is.
printf '%s' "$tag_job" | grep -qE '^[^#]*task check' \
&& ok 'the release-bump commit is gated before it is pushed' \
|| no 'the release-bump commit is pushed with no gate -- nothing ever tests it (#46)'
printf '\nci_gate: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+82
View File
@@ -24,6 +24,18 @@ if [ ! -f "$DOCTOR" ]; then
exit 1
fi
# The toolchain check (golangci-lint build-Go vs GOROOT) reads the real host
# unless we say otherwise, which made every unrelated fixture fail on a machine
# whose golangci-lint is genuinely stale. Give the whole suite a matching fake
# pair so only the cases that OPT IN exercise that check.
STUB_BIN="$WORK/_stub_bin"
mkdir -p "$STUB_BIN"
printf '#!/bin/sh\nprintf "golangci-lint has version 2.0.0 built with go1.27.0 from abc\\n"\n' > "$STUB_BIN/golangci-lint"
printf '#!/bin/sh\ncase "$1" in version) printf "go version go1.27.0 linux/amd64\\n";; *) printf "\\n";; esac\n' > "$STUB_BIN/go"
chmod +x "$STUB_BIN/golangci-lint" "$STUB_BIN/go"
PATH="$STUB_BIN:$PATH"
export PATH
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
@@ -291,5 +303,75 @@ else
fail=$((fail + 1)); printf ' FAIL --online reports an unreachable remote as its own fault\n rc=%s got: %s\n' "$_rc" "$_out"
fi
# --- toolchain: golangci-lint must be built with Go >= GOROOT ---------------
# Measured on koala 2026-09-08 (infra#375, second recurrence): golangci-lint
# 2.11.4 built with go1.26.1 against a go1.27.0 GOROOT PANICS on any Go repo --
#
# panic: file requires newer Go version go1.27 (application built with go1.26)
#
# and the earlier form of the same fault printed a typecheck error naming YOUR
# file, so the natural response is to debug code that is fine. It cost time in
# two repos. CI never sees it (the runner image matches), so it breaks only the
# local `task check` that AGENT.md requires before every push.
#
# The fixtures inject fake `go` and `golangci-lint` on PATH; nothing here reads
# the real host toolchain, so the suite still passes on a machine without Go.
mk_toolchain() { # $1=fixture $2=lint-build-go $3=goroot-go ("" = absent)
_b="$1/bin"; mkdir -p "$_b"
if [ -n "$2" ]; then
printf '#!/bin/sh\nprintf "golangci-lint has version 2.11.4 built with %s from abc on 2026-01-01\\n"\n' "$2" > "$_b/golangci-lint"
chmod +x "$_b/golangci-lint"
fi
if [ -n "$3" ]; then
printf '#!/bin/sh\ncase "$1" in version) printf "go version %s linux/amd64\\n";; *) printf "\\n";; esac\n' "$3" > "$_b/go"
chmod +x "$_b/go"
fi
printf '%s' "$_b"
}
f=$(new_fixture golangci_old); wire_clean "$f"
b=$(mk_toolchain "$f" go1.26.1 go1.27.0)
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
if [ "$_rc" -ne 0 ] && printf '%s' "$_out" | grep -qi 'golangci'; then
pass=$((pass + 1)); printf ' ok golangci-lint older than GOROOT is reported\n'
else
fail=$((fail + 1)); printf ' FAIL golangci-lint older than GOROOT is reported\n rc=%s got: %s\n' "$_rc" "$_out"
fi
# Matching versions are the normal case and must stay silent.
f=$(new_fixture golangci_ok); wire_clean "$f"
b=$(mk_toolchain "$f" go1.27.0 go1.27.0)
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok golangci-lint matching GOROOT is silent\n'
else
fail=$((fail + 1)); printf ' FAIL golangci-lint matching GOROOT is silent\n rc=%s got: %s\n' "$_rc" "$_out"
fi
# A NEWER lint than GOROOT is fine -- the constraint is one-directional.
f=$(new_fixture golangci_newer); wire_clean "$f"
b=$(mk_toolchain "$f" go1.28.0 go1.27.0)
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok golangci-lint newer than GOROOT is not a fault\n'
else
fail=$((fail + 1)); printf ' FAIL golangci-lint newer than GOROOT is not a fault\n rc=%s got: %s\n' "$_rc" "$_out"
fi
# Fail OPEN on anything unparseable. doctor runs in a SessionStart hook on every
# machine; a toolchain it cannot read is not a fault it may assert. (A host with
# no Go at all takes the same path -- command -v simply misses.)
f=$(new_fixture golangci_garbled); wire_clean "$f"
_b="$f/bin"; mkdir -p "$_b"
printf '#!/bin/sh\nprintf "golangci-lint dev build\\n"\n' > "$_b/golangci-lint"
printf '#!/bin/sh\nprintf "go version devel\\n"\n' > "$_b/go"
chmod +x "$_b/golangci-lint" "$_b/go"
_out=$(PATH="$_b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok an unreadable toolchain version is not asserted as a fault\n'
else
fail=$((fail + 1)); printf ' FAIL an unreadable toolchain version is not asserted as a fault\n rc=%s got: %s\n' "$_rc" "$_out"
fi
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]