Install
$ agentstack add skill-supermodo-skills-sync-gitlab ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Sync markdown roadmap → GitLab CE
> Requires: the sibling protocols skill (shared protocol masters) and a valid skills.config.json (create with the config skill). Missing either → halt with that exact pointer; never guess.
You mirror this project's markdown work docs into GitLab CE one-way. Markdown is the source of truth; you NEVER write back to markdown. You act only on entities this package owns (see Identity). Everything else on the server is read-only to you.
> Cross-tool note (Claude Code ↔ Codex). Written in host-neutral terms. > Under Claude Code: the Bash tool runs curl/jq; Read/Grep read markdown. Under > Codex: exec_command runs curl/jq; native file reads / rg. Codex > approval/escalation ⇔ Claude Code permission prompts. Shell state does not > survive between separate invocations — set the secret variables and define > gl_curl in the SAME invocation that makes each API call.
Scope (v1)
In: the project's deliverables → GitLab Issues, and each tasks.md checklist line → a native GitLab child Task (issue_type=task) linked to its parent Issue via the work-items parent_id / hierarchyWidget.
Explicitly OUT (deferred, do not attempt): boards, issue templates, project memberships/invites, and milestone management. If the roadmap implies any of these, note it in the report — never mutate them.
Step 0 — Config (validate before anything)
Read skills.config.json at the project root and validate per ../protocols/references/config.md. sync-gitlab requires the gitlab section and docs:
"gitlab": {
"url": "https://gitlab.example.com", // MUST be https://
"project": "group/name", // GitLab project path
"tokenEnv": "SUPERMODO_GITLAB_TOKEN", // PAT env var NAME (SUPERMODO_* only)
"cloudflareAccess": { // optional
"clientIdEnv": "SUPERMODO_CF_CLIENT_ID",
"clientSecretEnv": "SUPERMODO_CF_CLIENT_SECRET"
}
}
Halt (point the user to config) if: configVersion ≠ 1; gitlab missing; url not https://; project absent; tokenEnv absent or not starting with SUPERMODO_; any *Env value not starting with SUPERMODO_. Never guess.
Derive once, without interpolating config values into shell strings:
API="/api/v4" # e.g. https://gitlab.example.com/api/v4
GQL="/api/graphql"
PROJ= # group/name -> group%2Fname
Step 1 — Secrets (NEVER source a dotenv)
Read ONLY the variables named by config. Prefer process env (if the named vars are exported, use them). Otherwise strict .env parse: read line-by-line as KEY=VALUE, take ONLY the config-named keys, and halt on any line whose value contains $, backtick, (, ), ;. Never source, never eval, never run the file through a shell.
# read_secret VARNAME FILE -> echoes value on stdout, empty if absent/rejected
read_secret() {
local key="$1" file="$2"
[ -f "$file" ] || return 0
while IFS= read -r line; do
case "$line" in
"$key="*)
local val="${line#*=}"
case "$val" in *'$'*|*'`'*|*'('*|*')'*|*';'*)
echo "Halt: unsafe characters in $key; refusing to parse." >&2; exit 1 ;;
esac
printf '%s' "$val"; return 0 ;;
esac
done (https verified)
Project:
Headers: Authorization: Bearer [value hidden]
CF-Access-Client-Id / CF-Access-Client-Secret [only if configured]
Proceed with a read-only /user probe? [y/N]
On y, define the wrapper and probe (same invocation). The wrapper adds CF headers only when configured:
gl_curl() {
local args=(-sS --fail-with-body -H "Authorization: Bearer $GL_TOKEN")
[ -n "$CF_ID" ] && args+=(-H "CF-Access-Client-Id: $CF_ID" -H "CF-Access-Client-Secret: $CF_SECRET")
curl "${args[@]}" "$@"
}
gl_curl "$API/user" | jq -r '.username'
Secrets pass ONLY through -H arguments with shell variables — never in a URL or -d string. HTML response (/ (spec.md, plan.md, tasks.md) → one parent Issue. Title/summary from spec.md; acceptance from ## Acceptance (else ## Requirements). BACKLOG entries promoted to triads are in scope; struck-through history is not.
- Child Tasks — every checklist line in
tasks.md→ one child Task.
Identity is the immutable inline ID `, NEVER list position or title. Line shape: - [] . : pending · / in-progress · x done · ^/- paused. Done and paused → Task closed; pending and in-progress → open. Halt with file:line on a checklist line missing its task:` marker or a duplicate slug within a file.
Build a model: deliverables keyed by slug, each with ordered tasks[] keyed by task-slug. Halt on duplicate deliverable slug; never auto-fix markdown — surface file:line and stop.
Identity — the ownership marker (read this before any write)
Every entity this package manages carries BOTH:
- a label in the
supermodo:*namespace — at minimumsupermodo:managed,
plus supermodo:task: (child Tasks) / supermodo:work: (parent Issues) encoding the source ID; and
- a description footer line, exactly:
` where ` is the deliverable or task slug.
The source ID is generated deterministically from the markdown slug — stable across runs. Returned GitLab iid/id are persisted to the map (below).
> Quarantine / read-only rule (absolute). Any GitLab Issue or Task that does > NOT carry the supermodo:* marker is READ-ONLY: no updates, no closes, no > label changes, no reparenting. It is invisible to mutation. This subsumes any > project's suggestion/quarantine convention — no marker, no touch, full stop.
State files (symlink-contained)
Under .skills/supermodo/ (gitignored). Before EVERY read/write, resolve real paths (follow symlinks) and halt unless the destination stays beneath the project's real .skills/supermodo/ — per ../protocols/references/reports.md. Write temp-then-rename in the same directory.
gitlab-map.json— `{ "": { "kind": "issue|task", "iid": N,
"id": N, "parentIid": N|null } }`. The authoritative source-id → GitLab-id map.
gitlab-journal.jsonl— durable mutation journal (Recovery, below).
Step 4 — Diff (read-only API calls, paginated)
Compute per-entity CREATE | UPDATE | CLOSE | REOPEN | UNCHANGED. Pagination is uniform on EVERY collection (issues, tasks, labels): per_page=100, follow the x-next-page header until exhausted. Never read only page 1.
# paginate: echoes concatenated JSON arrays' elements. $1 = full URL sans page.
gl_page() {
local url="$1" page=1 next
while :; do
local hdr body
body="$(gl_curl -D /tmp/h.$$ "$url&per_page=100&page=$page")" || return 1
echo "$body" | jq -c '.[]'
next="$(awk -F': ' 'tolower($1)=="x-next-page"{gsub(/\r/,"",$2);print $2}' /tmp/h.$$)"
[ -n "$next" ] || break; page="$next"
done; rm -f /tmp/h.$$
}
gl_page "$API/projects/$PROJ/issues?state=all" # parents + tasks (filter issue_type client-side)
gl_page "$API/projects/$PROJ/labels" # for merge-preserve
Split issues into parents (issue_type null/issue) and tasks (issue_type=="task"). Match model entities to server entities by the supermodo:id= footer / supermodo:* label ONLY — not by title. Entities with no marker are skipped (read-only rule). For each managed entity decide the action; for child Tasks also detect missing/wrong parent link.
Record, per matched entity, the server updated_at — the preview snapshot used by the concurrency check.
Step 5 — Preview + mutation approval (separate, explicit)
Present a COMPLETE diff — every entity, its action, and the exact fields/labels that change:
Proposed GitLab mutations (project ):
Issues: create, update, close, reopen
Tasks: create, update, close, reopen, parent-link
Read-only (no supermodo marker, skipped):
CREATE issue work: "" labels: supermodo:managed,supermodo:work:
UPDATE task task: fields: state x→closed; +supermodo:task:
... (list ALL, not a sample, when the batch is small; page long batches)
Proceed with these mutations? [y/N]
Wait for an explicit y/yes in a SEPARATE turn — never a shell read, never the same turn as the preview. This confirmation is DISTINCT from the Step-2 auth acknowledgement. Anything else halts cleanly, zero mutations. If the count is 0, print "No changes — already in sync." and exit.
Step 6 — Apply (writes, journaled, conflict-checked)
Order: parent Issues, then their child Tasks (create parent before its tasks so parentIid exists). Per entity:
a. Journal planned — append one JSONL line before the mutation: {"entity":"","action":"...","before":, "srcHash":"","ts":""}.
b. Concurrency refetch — immediately GET this entity fresh and compare its updated_at to the preview snapshot. If it changed, someone else edited it: ABORT this entity, do not mutate, and re-run Diff→Preview for it (re-preview).
c. Mutate. Secrets only via -H; JSON bodies built with jq -n (never string interpolation of values):
# CREATE parent Issue
BODY=$(jq -n --arg t "$title" --arg d "$desc$FOOTER" --arg l "supermodo:managed,supermodo:work:$slug" \
'{title:$t, description:$d, labels:$l}')
RESP=$(gl_curl -X POST "$API/projects/$PROJ/issues" -H "Content-Type: application/json" -d "$BODY")
IID=$(echo "$RESP" | jq -r .iid); GID=$(echo "$RESP" | jq -r .id)
# CREATE child Task, then parent-link via GraphQL work-items
BODY=$(jq -n --arg t "$title" --arg d "$desc$FOOTER" \
--arg l "supermodo:managed,supermodo:task:$slug" \
'{title:$t, description:$d, issue_type:"task", labels:$l}')
TRESP=$(gl_curl -X POST "$API/projects/$PROJ/issues" -H "Content-Type: application/json" -d "$BODY")
TGID="gid://gitlab/WorkItem/$(echo "$TRESP" | jq -r .id)"
PGID="gid://gitlab/WorkItem/$GID"
Q='mutation($id:WorkItemID!,$p:WorkItemID!){workItemUpdate(input:{id:$id,hierarchyWidget:{parentId:$p}}){errors}}'
gl_curl -X POST "$GQL" -H "Content-Type: application/json" \
-d "$(jq -n --arg q "$Q" --arg id "$TGID" --arg p "$PGID" '{query:$q,variables:{id:$id,p:$p}}')"
d. Label preservation (CRITICAL). GitLab PUT labels REPLACES the full set. Before any update: take the current labels from the fresh GET, keep EVERY label verbatim, and change ONLY supermodo:* labels (add/remove within that namespace only). Then PUT the merged set. Touch no other namespace.
KEEP=$(echo "$current" | jq -r '[.labels[] | select(startswith("supermodo:")|not)] | join(",")')
NEW="$KEEP,supermodo:managed,supermodo:task:$slug" # our namespace, recomputed
gl_curl -X PUT "$API/projects/$PROJ/issues/$iid" -H "Content-Type: application/json" \
-d "$(jq -n --arg l "$NEW" --arg s "close" '{labels:$l, state_event:$s}')"
e. Post-create duplicate guard. Immediately after each CREATE, re-query the project for the source-ID marker (supermodo:work: / supermodo:task:). If MORE THAN ONE entity carries it, a concurrent run created a twin: HALT for deterministic reconciliation (report both iids; do not auto-delete). Otherwise persist iid/id/parentIid to gitlab-map.json.
f. Journal done — append {"entity":...,"action":...,"after":,"iid":N,"ts":...}.
Dependency failure: if a parent Issue create/update fails, STOP — do not create its child Tasks (they would orphan). Record it; the unfinished tail stays journaled for resume.
Per-mutation errors: 2xx → log+continue; 409 on create → treat as exists, verify via marker, continue; other 4xx → halt Apply, print body; 5xx → log+continue (Verify surfaces drift); network error → halt.
Recovery / resume
On invocation, if gitlab-journal.jsonl has planned entries without a matching done, the previous run was interrupted. Resume is idempotent:
- Recompute the markdown + config hash. If it differs from the journal's
srcHash, the source changed → invalidate: discard the plan, re-run Diff→Preview from scratch (re-confirm).
- Per entity, refetch and compare
updated_atto the journal ref. If it
differs, the remote changed → re-preview that entity, don't blind re-apply.
- Only entities whose source AND remote are unchanged resume from the journal.
Stop on the first dependency failure as in Apply.
Journal + map make re-runs deterministic: a clean re-run with no markdown change produces ZERO mutations.
Step 7 — Verify
Re-run the paginated Diff read-only; confirm every planned entity reached its desired state and each child Task's parent link is intact. Report (do NOT auto-fix) drift: 2xx-but-unchanged, expected-but-failed, unexpected new entities (concurrent editor), unparented tasks. A re-run reconciles deterministically.
Final report
Persist to .skills/supermodo/sync-gitlab/.md (per ../protocols/references/reports.md) and summarize in chat:
sync-gitlab —
Host/project: /
Parsed: deliverables, tasks
Applied: issues c/u/cl/r · tasks c/u/cl/r/parent-linked
Skipped (no marker, read-only):
Failed: Drift: Duplicates halted:
Result:
Never write back to markdown. Never auto-commit. Chat reporting follows output.verbosity (default concise); safety output (auth/mutation confirmations, warnings) is never compressed.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: supermodo
- Source: supermodo/skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.