Compare commits

..
12 Commits
Author SHA1 Message Date
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
mathiasandClaude Opus 5 8359d61e33 test(hooks): pin the pre-task-ritual hook to its registration
CI / Tag release (push) Successful in 1s
CI / Check / Test (push) Successful in 2s
Closes #44.

That issue overstated the risk and this corrects it. The hook is three lines
and echoes a string -- no bash-isms, so it cannot fail the way secret-guard
appeared to. The real exposure is narrower and duller: it can stop working
silently, and its entire job is to be visible.

THE ASSERTION THAT MATTERS IS REGISTRATION. A hook file that exists but is
absent from hooks.json does nothing at all and looks identical on disk to one
that works. So the test derives the command from hooks.json and runs it exactly
as registered, substituting CLAUDE_PLUGIN_ROOT the way the harness does, rather
than inventing its own invocation.

That choice is the lesson from #44's sibling. secret_guard_test.sh piped its
hook into `sh` while hooks.json runs it with `bash`; on Debian /bin/sh is dash,
which has no `set -o pipefail`, so the suite failed 20/20 in CI against a hook
that was FINE in production. The test was wrong, not the hook -- and the
earlier commit message for that fix said the shebang decides the interpreter,
which hooks.json shows is not true. Deriving the invocation removes the whole
class rather than fixing one instance of it.

No red phase to report: the hook was already correct, so this is regression
pinning, not TDD. Teeth proven by mutation against a throwaway copy instead:

  - hook runs but prints nothing      -> 6 failures
  - hook removed from hooks.json      -> caught, suite stops early
  - ONE ritual step dropped, rule 0
    still named                       -> caught (9 passed, 1 failed)

The third is the one worth having. Losing the whole line is obvious; quietly
losing "failing test" from the ritual while the banner still reads correctly is
the drift that would actually happen.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:33:52 +02:00
mathias 400d2b9b6b chore(release): plugin.json version 0.19.3
Skip-Release: true
2026-09-07 20:23:31 +00:00
mathiasandClaude Opus 5 4edf42ce9f fix(test): run the secret guard via its own shebang, not through sh
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 0s
The suite piped hook input into `sh "$GUARD"`, which overrides the hook's
`#!/usr/bin/env bash` and tests an interpreter production never uses. Claude
Code executes the hook as a program.

On Debian /bin/sh is dash, which has no `set -o pipefail`, so the guard died
on line 23 before reading a byte of input:

    secret-guard.sh: 23: set: Illegal option -o pipefail
    secret_guard: 0 passed, 20 failed

while passing 20/20 on a host where /bin/sh happens to be bash. A security
gate that tests green and cannot start is the worst of both, and the test was
the only thing standing between those two states.

Found because baked-v16 added jq to the runner: this suite needs jq to build
its hook JSON, so it had been silently doing nothing in CI and started running
for the first time. Same shape as installing zip and immediately learning the
gitea backup check had never run -- a missing tool does not announce that it
is suppressing a whole suite.

Also asserts the hook is executable, since direct invocation depends on the
exec bit, and that assertion is what stops this regressing to `sh "$GUARD"`.

Checked the rest of the suite: every other `sh "$SUT"` targets a POSIX script,
which is correct. hooks/pre-task-ritual.sh is the other bash-shebang hook and
has no test at all -- filed separately rather than fixed here.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:23:06 +02:00
mathiasandClaude Opus 5 2f53346b8c docs(check): say plainly that CI can never verify the MCP env wiring
CI / Check / Test (push) Failing after 2s
CI / Tag release (push) Skipped
infra#368 added jq to the gitea-runner image on the stated grounds that
check-mcp-env would then verify in CI instead of skipping. Verified after
rolling baked-v16: jq IS present now, the `SKIP: jq not installed` branch is
gone -- and the check still reports unverified, because CI has no ~/.op-env.

The env-file branch was always the binding one. It holds live credentials, so
it is not on the runner and must not be, which means this check can only ever
verify on a host that has one. Putting ~/.op-env on the runner to turn the
NOTE green would be trading a real secret for a green tick.

So the message now says so, at the point someone reads it. The risk otherwise
is not the skip -- it is a future reader seeing a passing pipeline and
concluding the MCP wiring is covered. It is covered on developer hosts only.

jq was still worth adding: it is a genuine dependency of this checker, the
runner's tool-contract.txt now asserts it, and a future image built without it
is a red pod at roll time rather than a silently degraded check.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:21:10 +02:00
mathias dcc0677270 chore(release): plugin.json version 0.19.2
Skip-Release: true
2026-09-07 18:14:53 +00:00
mathiasandClaude Opus 5 9fd5a054b3 fix(check): tolerate an unverifiable check instead of dying on it
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
bin/check-mcp-env exits 2 for "could not run" -- no jq, or no env-file --
and the gate was written to report that as a NOTE rather than fail:

    "$DIR/check-mcp-env" "$DIR/.mcp.json" "$HOME/.op-env"
    case $? in 0) ;; 2) echo "NOTE: ..." ;; *) fail=1 ;; esac

That tolerance never executed. Task runs each cmds block under `sh -e`, so
a bare command exiting non-zero aborts the shell before `case $?` is
reached. main went red on the very commit that added the check (run #57),
with `task: Failed to run task "check": exit status 2`.

It passed locally because this host has both jq and ~/.op-env, so the
check exited 0 and the untested branch never ran. CI has neither. A
tolerated exit code that is only tolerated where it never fires is not
tolerated at all.

`|| rc=$?` puts the invocation in a compound command, which `set -e`
exempts, so `case` is reached. Both exit-2 paths verified: no env-file
(the new test's precondition) and no jq (PATH sandbox, same SKIP message
CI printed).

The test pins the tolerance itself rather than the checker: it points HOME
at an empty directory, which is exactly CI's condition, and asserts `task
check` exits 0 AND still says it could not verify -- silence would mean
skipped rather than tolerated.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:14:19 +02:00
12 changed files with 506 additions and 4 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "skills", "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.", "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.0", "version": "0.19.6",
"author": { "author": {
"name": "Mathias", "name": "Mathias",
"url": "https://git.d-ma.be/mathias" "url": "https://git.d-ma.be/mathias"
+27
View File
@@ -133,6 +133,33 @@ jobs:
git add "$manifest" git add "$manifest"
# The trailer below stops this push from re-triggering the tagger. # The trailer below stops this push from re-triggering the tagger.
git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true" 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 if git push origin HEAD:main; then
echo "bumped $manifest to $version" echo "bumped $manifest to $version"
exit 0 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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `feature-spec` | Implementation-level feature spec (tighter than spec-driven-dev) | Scoping one feature inside an already-specced project | claude-code, library |
+7 -2
View File
@@ -145,8 +145,13 @@ tasks:
# not run (no jq, or no env-file — CI has neither); that is reported # not run (no jq, or no env-file — CI has neither); that is reported
# and not fatal, but it is never treated as a pass. # and not fatal, but it is never treated as a pass.
if [ -x "{{.TASKFILE_DIR}}/bin/check-mcp-env" ]; then if [ -x "{{.TASKFILE_DIR}}/bin/check-mcp-env" ]; then
"{{.TASKFILE_DIR}}/bin/check-mcp-env" "{{.TASKFILE_DIR}}/.mcp.json" "$HOME/.op-env" # `|| rc=$?` is load-bearing, not style. Task runs this block under
case $? in # `sh -e`, so a bare invocation exiting non-zero kills the shell
# before `case` is reached -- the tolerance below never ran, and the
# gate died with `exit status 2` on the commit that added it.
rc=0
"{{.TASKFILE_DIR}}/bin/check-mcp-env" "{{.TASKFILE_DIR}}/.mcp.json" "$HOME/.op-env" || rc=$?
case $rc in
0) ;; 0) ;;
2) echo "NOTE: MCP env wiring unverified on this host" >&2 ;; 2) echo "NOTE: MCP env wiring unverified on this host" >&2 ;;
*) fail=1 ;; *) fail=1 ;;
+10
View File
@@ -41,7 +41,17 @@ if [ ! -f "$CONFIG" ]; then
fi fi
if [ ! -f "$ENVFILE" ]; then if [ ! -f "$ENVFILE" ]; then
# EXPECTED IN CI, and permanently so. The env-file holds live credentials, so
# it is not on the runner and must not be -- which means this check can only
# ever verify on a host that has one. infra#368 added jq to the runner image
# believing that would let CI verify; it removed the jq branch below and
# changed nothing else, because this branch is the binding one.
#
# Said plainly so nobody concludes CI covers this. It does not, it cannot,
# and putting ~/.op-env on the runner to make it would be trading a real
# secret for a green tick.
echo "ERROR: no such env-file: $ENVFILE -- cannot verify MCP variables" >&2 echo "ERROR: no such env-file: $ENVFILE -- cannot verify MCP variables" >&2
echo " (expected in CI: this check only verifies on a host with ~/.op-env)" >&2
exit 2 exit 2
fi fi
+38
View File
@@ -327,6 +327,44 @@ if [ "$ONLINE" -eq 1 ]; then
fi fi
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 ----------------------------------------------------------------- # --- output -----------------------------------------------------------------
if [ "$JSON" -eq 1 ]; then if [ "$JSON" -eq 1 ]; then
printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \ 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.
@@ -0,0 +1,70 @@
#!/bin/sh
# `task check` must survive a check that reports "could not verify".
#
# The regression these exist for: bin/check-mcp-env exits 2 to mean "I could
# not run — no jq, or no env-file", which the gate deliberately treats as a
# reported NOTE rather than a failure. The Taskfile said exactly that:
#
# "$DIR/check-mcp-env" "$DIR/.mcp.json" "$HOME/.op-env"
# case $? in 0) ;; 2) echo "NOTE: ..." ;; *) fail=1 ;; esac
#
# and it did not work. Task runs each cmds block under `sh -e`, so a bare
# command exiting non-zero aborts the shell *before* `case $?` is ever
# reached. The whole gate died with `exit status 2`. Locally the check found
# jq and exited 0, so it went green; CI has neither jq nor ~/.op-env, so main
# went red on the very commit that added it (skills run #57, 2026-09-07).
#
# A tolerated exit code that is only tolerated on the host where it never
# fires is not tolerated at all. This pins the tolerance itself.
#
# No mocking: pointing HOME at an empty directory removes ~/.op-env, which is
# exactly the condition CI runs under, so check-mcp-env genuinely exits 2.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
if ! command -v task >/dev/null 2>&1; then
printf ' SKIP: task not installed -- cannot exercise the gate\n'
exit 0
fi
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# Sanity: the precondition must actually hold, or the test proves nothing.
# An empty HOME means no ~/.op-env, so check-mcp-env cannot verify anything.
"$ROOT/bin/check-mcp-env" "$ROOT/.mcp.json" "$TMP/.op-env" >/dev/null 2>&1
rc=$?
if [ "$rc" -eq 2 ]; then
ok 'precondition: check-mcp-env exits 2 when the env-file is absent'
else
no "precondition: expected check-mcp-env to exit 2, got $rc"
fi
# The assertion. `task check` must still pass, and must say why it could not
# verify -- silence would mean the check was skipped rather than tolerated.
out=$(HOME="$TMP" task --dir "$ROOT" check 2>&1)
rc=$?
if [ "$rc" -eq 0 ]; then
ok 'task check passes when MCP env wiring cannot be verified'
else
no "task check exited $rc when check-mcp-env reported unverifiable"
printf '%s\n' "$out" | sed 's/^/ | /' | tail -8
fi
if printf '%s' "$out" | grep -q 'unverified'; then
ok 'the unverifiable check is reported, not silently skipped'
else
no 'no NOTE reported -- an unverifiable check must never look like a pass'
fi
printf ' %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+26
View File
@@ -53,5 +53,31 @@ printf '%s' "$tag_job" | grep -qE '^ +needs: *gate' \
&& ok 'ungated release.yml removed' \ && ok 'ungated release.yml removed' \
|| no '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" printf '\nci_gate: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]
+82
View File
@@ -24,6 +24,18 @@ if [ ! -f "$DOCTOR" ]; then
exit 1 exit 1
fi 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"; } cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM 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" fail=$((fail + 1)); printf ' FAIL --online reports an unreachable remote as its own fault\n rc=%s got: %s\n' "$_rc" "$_out"
fi 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" printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]
+103
View File
@@ -0,0 +1,103 @@
#!/bin/sh
# Tests for hooks/pre-task-ritual.sh — the UserPromptSubmit hook that surfaces
# rule 0 on every prompt (#44).
#
# WHY THIS EXISTS, AND WHAT IT DELIBERATELY DOES NOT CLAIM
# This hook is three lines and echoes a string. It has no bash-isms, so it
# cannot fail the way secret-guard appeared to; that risk was overstated when
# this test was requested. What it CAN do is stop working silently, and its
# whole job is to be visible -- if the text stops appearing, rule 0 stops being
# surfaced on every prompt and nothing announces that.
#
# THE ASSERTION THAT MATTERS IS REGISTRATION.
# A hook file that exists but is not in hooks.json does absolutely nothing, and
# looks identical on disk to one that works. So this derives the command from
# hooks.json rather than assuming one: the registration is the source of truth
# for how the hook is invoked, and a test that invokes it its own way is
# testing something the harness never runs.
#
# That distinction is not hypothetical. secret_guard_test.sh piped its hook into
# `sh` while hooks.json runs it with `bash`; on Debian /bin/sh is dash, which
# has no `set -o pipefail`, and the suite failed 20/20 in CI against a hook that
# was fine in production. The test was wrong, not the hook. Deriving the
# invocation removes the opportunity for that class of mistake entirely.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
HOOK="$ROOT/hooks/pre-task-ritual.sh"
REG="$ROOT/hooks/hooks.json"
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
if ! command -v jq >/dev/null 2>&1; then
printf ' SKIP: jq not installed -- cannot read the hook registration\n'
exit 0
fi
[ -f "$HOOK" ] || { printf ' FAIL %s does not exist\n' "$HOOK"; exit 1; }
ok 'hook file exists'
[ -f "$REG" ] || { printf ' FAIL %s does not exist\n' "$REG"; exit 1; }
# 1. Registered, and on the right event. UserPromptSubmit is what makes it fire
# on every prompt; registered under any other event it would still "exist"
# and still do nothing useful.
CMD=$(jq -r '
.hooks.UserPromptSubmit[]?.hooks[]?
| select(.command | test("pre-task-ritual"))
| .command
' "$REG" 2>/dev/null | head -1)
if [ -n "$CMD" ]; then
ok 'registered on UserPromptSubmit in hooks.json'
else
no 'not registered on UserPromptSubmit -- the file would exist and never run'
printf ' %d passed, %d failed\n' "$pass" "$fail"
exit 1
fi
# 2. Run it EXACTLY as registered, with the harness variable substituted the way
# the harness substitutes it.
OUT=$(CLAUDE_PLUGIN_ROOT="$ROOT" sh -c "$CMD" 2>&1)
rc=$?
[ "$rc" -eq 0 ] && ok 'exits 0 as registered' \
|| no "exited $rc -- a non-zero UserPromptSubmit hook can block the prompt"
# 3. The content is the point. A hook that runs and says nothing is the same as
# no hook, and that is the failure this pins.
if [ -n "$OUT" ]; then
ok 'produces output'
else
no 'produced no output -- rule 0 would silently stop being surfaced'
fi
case "$OUT" in
*"Rule 0"*) ok 'names rule 0' ;;
*) no "does not name rule 0: '$OUT'" ;;
esac
# Each of the four steps, individually. Naming rule 0 while having quietly lost
# a step is the plausible drift, not losing the whole line.
for step in brain_query skill 'failing test' 'success criterion'; do
case "$OUT" in
*"$step"*) ok "states the '$step' step" ;;
*) no "lost the '$step' step from the ritual" ;;
esac
done
# 4. The interpreter named in the registration must actually exist, or the hook
# fails on that host with a message nobody reads.
INTERP=${CMD%% *}
if command -v "$INTERP" >/dev/null 2>&1; then
ok "registered interpreter '$INTERP' is available"
else
no "registered interpreter '$INTERP' not found on this host"
fi
printf ' %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+15 -1
View File
@@ -24,7 +24,7 @@ decision_for() {
_tool=${2:-Bash} _tool=${2:-Bash}
jq -n --arg t "$_tool" --arg c "$_cmd" \ jq -n --arg t "$_tool" --arg c "$_cmd" \
'{tool_name: $t, tool_input: {command: $c}}' \ '{tool_name: $t, tool_input: {command: $c}}' \
| sh "$GUARD" \ | "$GUARD" \
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"' | jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
} }
@@ -42,6 +42,20 @@ expect() {
fi fi
} }
# Invoked as a PROGRAM above, never as `sh "$GUARD"`. Claude Code executes the
# hook directly, so its `#!/usr/bin/env bash` shebang decides the interpreter;
# piping it into `sh` overrides that and tests something production never runs.
#
# It is not academic. On Debian /bin/sh is dash, which has no `set -o
# pipefail`, so the guard died on line 23 before reading a byte of input and
# every deny case returned empty -- 20 of 20 assertions failed in CI while
# passing on a host where /bin/sh happens to be bash. A security gate that
# tests green and cannot start is the worst of both.
if [ ! -x "$GUARD" ]; then
printf 'FAIL: %s is not executable -- it must run via its own shebang\n' "$GUARD"
exit 1
fi
if [ ! -f "$GUARD" ]; then if [ ! -f "$GUARD" ]; then
printf 'FAIL: %s does not exist\n' "$GUARD" printf 'FAIL: %s does not exist\n' "$GUARD"
exit 1 exit 1