Drop the Vikunja skill from the image
build / Build and push image (push) Successful in 3m34s

The image is published to a public registry, and the skill named the
employer as its Vikunja root project and pointed at tasks.mroberts.dev.
Neither is a secret, but neither belongs in an artefact anyone can pull.

It is a host-side planning skill with nothing to do inside a sandbox, so
removing it costs the sandbox no capability.
This commit is contained in:
2026-08-03 16:14:32 -05:00
parent 1617bb15df
commit b536a587c1
4 changed files with 0 additions and 490 deletions
-119
View File
@@ -1,119 +0,0 @@
---
name: vikunja-backlog
description: Use when you have a brainstorming, design, discussion, meeting-notes, or specification document (any format — md, txt, pdf, docx, odt) and want it turned into Vikunja backlog tasks under the "sliders, not checkboxes" feature-category planning model at tasks.mroberts.dev.
---
# Vikunja Backlog From a Document
## Overview
Turn a discussion/design/brainstorming document into Vikunja tasks using the
**"Sliders, Not Checkboxes"** framework: work is organized by **feature
category** (the project), each item is restated as the **need** it addresses
(the task title), the proposed solution lives in the description, and value is
expressed as **priority** (rank within the category).
The mapping and the 8 categories are the contract. Read
[reference/sliders-framework.md](reference/sliders-framework.md) before
classifying — it defines each category, the need-vs-solution discipline, and
the priority/label rules.
**Core principle:** the title is the *need*, never the solution. "Limit a
compromised container's blast radius", not "add read-only rootfs".
## When to Use
- You have a doc (any format) describing work, decisions, or a backlog and want
it in Vikunja.
- Symptoms: a design spec, meeting notes, a brainstorm, a PR/backlog writeup, a
Slack/email dump pasted into a file.
Not for: editing tasks one-off (use the API directly), or non-planning docs.
## Object Mapping (the contract)
| Framework concept | Vikunja object |
|---|---|
| Feature category | **Project** (child of the root `CareEvolution` project) |
| Candidate item | **Task** |
| Need | Task **title** (phrased as the goal) |
| Solution(s) + source | Task **description** |
| Value rank within category | Task **priority** (1–4) |
| Urgency / returns | Label `acute` / `diminishing` |
| Platform | Label (e.g. `Orchestrate`) |
| Deployment / repo | Label (e.g. `Rosetta`) |
The same task viewed by **project** = cross-deployment value lens; filtered by
**deployment label** = per-repo execution lens (one dataset, many views).
## Workflow
1. **Extract** the doc text:
`scripts/extract.py <path>` (handles md/txt/pdf/docx/odt → stdout).
2. **Analyze** the text. Pull out candidate items. For each, restate the
**need** (what the user/admin/dev is trying to accomplish), and capture the
proposed **solution(s)** separately. Discard pure discussion that isn't a
candidate. See the framework reference for the need-restatement discipline.
3. **Classify** each need into exactly one of the 8 categories. Borderline
items: pick the category of the *primary value*, note the alternative in the
description.
4. **Rank** — assign a priority (4 = most acute/frequent/broad … 1 = small/late
on the curve) reflecting value *within its category*, not build effort. Tag
`acute` / `diminishing` per the reference rules.
5. **Tag platform + deployment.** Infer the platform (tag) and deployment(s)
(label) from the doc, then **confirm/override with the user** before
creating. If a doc spans several deployments and granularity is "per
deployment", emit one task per (need × deployment).
6. **Propose, then create.** Present the full breakdown as a table
(need | category | priority | labels | done?). Get the user's approval/edits.
**Do not create anything before approval.**
7. On approval, write the approved tasks to a JSON file and run
`scripts/vikunja.py create --file <tasks.json>`. It idempotently ensures the
8 category projects + labels exist, creates missing labels, skips duplicates,
and handles the done-priority gotcha.
8. **Report** created task IDs and the view hints above.
## Scripts
- `scripts/extract.py <path>` — extract plain text from md/txt/pdf/docx/odt.
- `scripts/vikunja.py ensure` — idempotently create the 8 category projects +
`acute`/`diminishing` labels under `CareEvolution`. Prints name→id maps.
- `scripts/vikunja.py projects` / `labels` — list current ids.
- `scripts/vikunja.py create --file <tasks.json>` — create tasks. Run `--help`
for the JSON schema and all flags.
Auth: reads `VIKUNJA_API_KEY` from the environment. Base URL defaults to
`https://tasks.mroberts.dev/api/v1` (`VIKUNJA_BASE_URL` overrides); root project
defaults to `CareEvolution` (`VIKUNJA_ROOT_PROJECT` overrides).
## Tasks JSON schema
```json
[
{
"category": "Security, privacy & compliance",
"title": "Limit a compromised container's runtime blast radius",
"description": "**Need:** ...\n\n**Solution:** ...\n\n**Source:** doc.md",
"priority": 4,
"labels": ["Orchestrate", "Rosetta", "acute"],
"done": false,
"percent": 0
}
]
```
`category` must be one of the 8 exact names (see reference). `labels` are
created if missing. `done`/`percent` optional.
## Common Mistakes
- **Title is the solution, not the need.** "Add Dependabot auto-merge" → wrong.
"Stay patched with less manual toil" → right; Dependabot goes in the body.
- **Priority = build effort.** No. Priority = value of the need within its
category. A cheap rank-1 outranks an expensive rank-6.
- **Creating before approval.** The framework's whole point is the judgment
call on need + category + rank. Always propose first.
- **Re-running creates duplicates.** `create` dedupes on (title + category +
deployment labels); don't bypass it with ad-hoc API calls.
- **Mark-done zeros priority.** The Vikunja update replaces the task; `create`
re-sends priority on the done update. Don't hand-roll the done call.
@@ -1,98 +0,0 @@
# Sliders, Not Checkboxes — classification reference
The framework models work as a small set of **feature categories**. A backlog
item is restated as the **need** it addresses (not the proposed solution), and
needs are value-ranked within each category. Source: the "Sliders, Not
Checkboxes" paper (CareEvolution).
## The need-restatement discipline
Before an item enters a category, restate it as the user/admin/developer
**need** — what they are trying to accomplish — and treat the proposed solution
as *one way* to address it.
- A doc says "build a self-serve provisioning UI." The **need** is "add/remove a
user without filing a support ticket." The UI is one solution; SCIM or a
declarative API are others. Title = the need; list the candidate solutions in
the description.
- Ranking reflects what addressing the need means for the people who hold it:
**how often** it's hit, **how acute** the pain, **how broadly** held. Not what
is easiest to build or most recently requested.
- Discussion, debate, and context that isn't a candidate item → do not create a
task. Capture only genuine candidate needs.
## The 8 feature categories
Classify each need into exactly one. Pick the category of the **primary value**;
note a close alternative in the description.
| Category (use this exact name) | What it covers | Whose need |
|---|---|---|
| **End-user experience** | User-facing product: features, flows, learnability, polish | People using the front-end to do their own tasks |
| **Admin & operator experience** | Configuration, governance, monitoring, operational tooling | Customer-side admins / IT staff deploying & overseeing the product |
| **Developer experience & adoption** | API design, SDKs, docs, sandbox, time-to-first-call; community, mindshare | Developers integrating against or building on the product |
| **Data, integrations & ecosystem** | Data quality, lineage, connectors, standards conformance (FHIR, HL7, OAuth, OpenAPI), marketplace, partners | Integrating systems and partner platforms |
| **Performance & scale** | Latency, throughput, behavior under load | Anyone depending on the product being fast and handling their volume |
| **Reliability & observability** | Uptime, fault tolerance, graceful degradation, recovery; logs, metrics, traces, alerting, runbooks | Customers needing it up; operators keeping it up |
| **Security, privacy & compliance** | Authn/authz, encryption, audit logging, certifications, regulatory posture, attack-surface reduction, vulnerability/CVE management, supply-chain integrity, runtime threat detection | Security & compliance teams; regulators; users trusting the platform |
| **Cost efficiency** | Cost per unit work — per record, per API call, per active user; compute/arch cost (e.g. ARM64/Graviton) | Internal P&L; customers indirectly via pricing |
### Classification hints / common overlaps
- **Hardening** (read-only rootfs, drop caps, minimal/chiseled base images, image
CVE scanning, SHA-pinning CI actions, dependency patch currency, supply-chain
inventory, runtime threat detection) → **Security, privacy & compliance**.
- **ARM64/Graviton migration, smaller images for cost** → **Cost efficiency**
(its perf side can be **Performance & scale** — pick the primary driver named
in the doc).
- **Perf vs cost** are often two sides of one coin — categorize by the value the
doc emphasizes (latency/throughput → Performance; $/unit → Cost).
- **A correctness/data-race bug surfaced by infra work** → **Reliability &
observability** (it's about the service being correct/up), not Security.
- **CI/CD integrity** (pinning actions, blocking tampering) → Security; **CI
ergonomics/speed for devs** → Developer experience. Pick by the value.
- **Runtime anomaly detection** (GuardDuty etc.) → Security (threat detection)
even though it touches observability.
## Value rank → priority
Vikunja priority is 1–5. Use **1–4** for value tiers (reserve 5 for true
DO-NOW). Rank within the category, by value of the need:
| Priority | Meaning |
|---|---|
| **4** | Rank-1 tier: most acute / frequent / broadly held; foundational. Everything else in the category is downstream of it. |
| **3** | High value, clearly worth doing, but not the keystone. |
| **2** | Medium: real need, smaller delta over the status quo. |
| **1** | Small / late-on-the-curve: refinement, niche, or largely satisfied by another item. |
Priority reflects **value, not build effort**. A cheap rank-1 outranks an
expensive rank-6.
## Urgency / returns labels
- **`acute`** — the need is frequent, painful, and/or broadly held *right now*.
Apply to the rank-1/keystone needs and anything externally forced (regulatory
deadline, audit finding).
- **`diminishing`** — the next investment in this area has tipped into
diminishing returns, or the need is largely satisfied by another item already
in the plan. Apply sparingly; it's a signal to deprioritize.
A task can have neither. Most have neither.
## Platform & deployment labels
- **Platform** = the product/platform the work belongs to (e.g. `Orchestrate`,
`HBNG`). One label.
- **Deployment / repo** = the specific service or repo (e.g. `Rosetta`,
`Hendrix`, `Insight`, `Kong`). One per task when granularity is per-deployment.
Categories are consistent **across** platforms and deployments (that's the point
— cross-deployment comparability). Platform/deployment are tags, never top-level
projects.
## Done / in-progress
- If the doc says an item is already shipped for a deployment, set `done: true`.
- Partial progress: leave `done: false` and set `percent` (e.g. 50) — note what
remains in the description.
@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Extract plain text from a document for backlog analysis.
Supports: .md .markdown .rst .txt (read), .odt / .docx (zip + XML strip),
.pdf (pdftotext if available, else pypdf/pdfplumber). Writes text to stdout.
Usage: extract.py <path>
"""
import sys, os, re, html, zipfile, shutil, subprocess
def _strip_xml(xml: str, para_close=("</text:p>", "</text:h>", "</w:p>")) -> str:
for tag in para_close:
xml = xml.replace(tag, "\n")
xml = re.sub(r"<[^>]+>", "", xml)
xml = html.unescape(xml)
lines = [ln.strip() for ln in xml.split("\n")]
return "\n".join(ln for ln in lines if ln)
def from_zip_xml(path: str, inner: str) -> str:
with zipfile.ZipFile(path) as z:
return _strip_xml(z.read(inner).decode("utf-8", "ignore"))
def from_pdf(path: str) -> str:
if shutil.which("pdftotext"):
out = subprocess.run(["pdftotext", "-layout", path, "-"],
capture_output=True, text=True)
if out.returncode == 0 and out.stdout.strip():
return out.stdout
try:
import pypdf
return "\n".join(p.extract_text() or "" for p in pypdf.PdfReader(path).pages)
except Exception:
pass
try:
import pdfplumber
with pdfplumber.open(path) as pdf:
return "\n".join(pg.extract_text() or "" for pg in pdf.pages)
except Exception:
sys.exit("PDF extraction needs `pdftotext` (poppler) or `pip install pypdf`.")
def extract(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
if ext in (".md", ".markdown", ".rst", ".txt", ".text", ""):
with open(path, encoding="utf-8", errors="replace") as f:
return f.read()
if ext == ".odt":
return from_zip_xml(path, "content.xml")
if ext == ".docx":
return from_zip_xml(path, "word/document.xml")
if ext == ".pdf":
return from_pdf(path)
# last resort: try as text
try:
with open(path, encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
sys.exit(f"Unsupported binary format: {ext}")
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("usage: extract.py <path>")
if not os.path.isfile(sys.argv[1]):
sys.exit(f"no such file: {sys.argv[1]}")
sys.stdout.write(extract(sys.argv[1]))
@@ -1,204 +0,0 @@
#!/usr/bin/env python3
"""Vikunja backlog helper for the sliders/feature-category model.
Idempotently ensures the 8 feature-category projects + framework labels exist
under the root project, and creates tasks from an approved JSON breakdown
(deduping, and working around the mark-done-zeros-priority quirk).
Auth/config (environment):
VIKUNJA_API_KEY required — long-lived API token
VIKUNJA_BASE_URL default https://tasks.mroberts.dev/api/v1
VIKUNJA_ROOT_PROJECT default CareEvolution
Subcommands:
ensure create missing category projects + acute/diminishing labels
projects list category projects (name -> id)
labels list labels (name -> id)
create --file T.json create tasks from an approved breakdown (see --help)
Tasks JSON: a list of objects:
{category, title, description, priority, labels:[...], done?, percent?}
`category` must be one of the 8 exact names. Missing labels are created.
Duplicates (same title + category + deployment labels) are skipped.
"""
import sys, os, json, argparse, urllib.request, urllib.error
BASE = os.environ.get("VIKUNJA_BASE_URL", "https://tasks.mroberts.dev/api/v1")
ROOT_NAME = os.environ.get("VIKUNJA_ROOT_PROJECT", "CareEvolution")
CATEGORIES = [
"End-user experience",
"Admin & operator experience",
"Developer experience & adoption",
"Data, integrations & ecosystem",
"Performance & scale",
"Reliability & observability",
"Security, privacy & compliance",
"Cost efficiency",
]
FRAMEWORK_LABELS = ["acute", "diminishing"]
def _key():
k = os.environ.get("VIKUNJA_API_KEY")
if not k:
sys.exit("VIKUNJA_API_KEY not set")
return k
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
BASE + path, data=data, method=method,
headers={"Authorization": "Bearer " + _key(),
"Content-Type": "application/json"})
try:
resp = urllib.request.urlopen(req)
return resp.status, json.loads(resp.read() or "null")
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:300]
def get(path):
return call("GET", path)[1]
def root_id():
for p in get("/projects") or []:
if p["title"] == ROOT_NAME and p.get("parent_project_id") in (0, None):
return p["id"]
sys.exit(f"root project {ROOT_NAME!r} not found")
def all_labels():
return {l["title"]: l["id"] for l in (get("/labels") or [])}
def category_projects():
rid = root_id()
return {p["title"]: p["id"] for p in (get("/projects") or [])
if p.get("parent_project_id") == rid and p["title"] in CATEGORIES}
def ensure_label(name, cache):
if name in cache:
return cache[name]
s, l = call("PUT", "/labels", {"title": name})
if not isinstance(l, dict):
sys.exit(f"failed creating label {name!r}: {l}")
cache[name] = l["id"]
return l["id"]
def cmd_ensure(_):
rid = root_id()
existing = category_projects()
cats = dict(existing)
for c in CATEGORIES:
if c not in cats:
s, p = call("PUT", "/projects", {"title": c, "parent_project_id": rid})
if not isinstance(p, dict):
sys.exit(f"failed creating project {c!r}: {p}")
cats[c] = p["id"]
print(f"created project {c!r} -> {p['id']}", file=sys.stderr)
labels = all_labels()
for n in FRAMEWORK_LABELS:
ensure_label(n, labels)
print(json.dumps({"root": rid, "categories": cats, "labels": labels}, indent=2))
def cmd_projects(_):
print(json.dumps(category_projects(), indent=2))
def cmd_labels(_):
print(json.dumps(all_labels(), indent=2))
def _deployment_labels(labels):
return {l for l in labels if l not in FRAMEWORK_LABELS}
def cmd_create(args):
tasks = json.load(open(args.file))
if not isinstance(tasks, list):
sys.exit("tasks JSON must be a list")
rid = root_id()
cats = category_projects()
# ensure all referenced categories exist
for t in tasks:
c = t["category"]
if c not in CATEGORIES:
sys.exit(f"unknown category {c!r} (must be one of the 8)")
if c not in cats:
s, p = call("PUT", "/projects", {"title": c, "parent_project_id": rid})
cats[c] = p["id"]
label_cache = all_labels()
# cache existing tasks per category for dedup
existing = {}
for c, pid in cats.items():
existing[c] = get(f"/projects/{pid}/tasks") or []
created, skipped = [], []
for t in tasks:
c = t["category"]
pid = cats[c]
title = t["title"]
want_dep = _deployment_labels(t.get("labels", []))
dup = False
for ex in existing[c]:
if ex["title"].strip().lower() != title.strip().lower():
continue
ex_labels = {l["title"] for l in (ex.get("labels") or [])}
if want_dep.issubset(ex_labels):
dup = True
break
if dup:
skipped.append((title, c, sorted(want_dep)))
continue
prio = int(t.get("priority", 0))
s, task = call("PUT", f"/projects/{pid}/tasks", {
"title": title,
"description": (t.get("description") or "").replace("\n", "<br>"),
"priority": prio,
})
if not isinstance(task, dict):
sys.exit(f"create failed for {title!r}: {task}")
tid = task["id"]
for ln in t.get("labels", []):
call("PUT", f"/tasks/{tid}/labels", {"label_id": ensure_label(ln, label_cache)})
done = bool(t.get("done"))
pct = int(t.get("percent", 0))
if done or pct:
# re-send priority: the update replaces the task and would zero it
call("POST", f"/tasks/{tid}", {
"done": done, "percent_done": pct / 100, "priority": prio})
existing[c].append({"title": title,
"labels": [{"title": x} for x in t.get("labels", [])]})
created.append((tid, title, c, prio, sorted(want_dep), "done" if done else (f"{pct}%" if pct else "")))
print(f"created {len(created)} tasks, skipped {len(skipped)} duplicate(s)", file=sys.stderr)
for r in created:
print(f" + #{r[0]} [{r[3]}] {r[2]}: {r[1]} {r[4]} {r[5]}", file=sys.stderr)
for r in skipped:
print(f" = skip {r[1]}: {r[0]} {r[2]}", file=sys.stderr)
print(json.dumps({"created": [r[0] for r in created],
"skipped": len(skipped)}))
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("ensure", help="create missing category projects + labels")
sub.add_parser("projects", help="list category projects")
sub.add_parser("labels", help="list labels")
c = sub.add_parser("create", help="create tasks from approved JSON")
c.add_argument("--file", required=True, help="path to tasks JSON (list)")
args = ap.parse_args()
{"ensure": cmd_ensure, "projects": cmd_projects,
"labels": cmd_labels, "create": cmd_create}[args.cmd](args)
if __name__ == "__main__":
main()