CI routing: which CI backends exist (from a config file), whether they are usable (token, runners, jobs), and which repo or conversation uses which. /ci, /ci…

Mods for Claude Code: small panes that show what your agents do. They never block or rewrite a tool call; at most they warn.
Needs Claude Code 2.1.287 or later (function-hook mods, panes, Svg element).
| Mod | Command | What it shows | ||||
|---|---|---|---|---|---|---|
factory | /factory, /factory <title or id> | Dashboard of every conversation (model, effort, context %, cost today, running subagents, PR/MR and CI, waiting on you). Click one for its agent tree: main thread, each subagent and Workflow node with model, effort, tokens and estimated cost, the bill per model and per turn, refused tool calls, the session log and warnings (subagent without an explicit model, Opus doing read-only search, big context without handoff/RESUME.md, timer tools). | ||||
control | `/control [setup\ | usage\ | practices\ | history] [scan]` | SETUP: everything that shapes a session (settings, env, permissions, global and repo CLAUDE.md, agents, skills, hook scripts, mods, scheduled tasks, MCP servers, plugins, memory), with hidden rules and any blocker (hook, deny rule, mod deny path) at the top. USAGE: 1/7/30-day model, effort and delegation mix from the transcripts, each metric against a target. PRACTICES and HISTORY: your rules file, followed or not, and its version history. | |
ci | /ci [refresh], `/ci route <repo\ | session\ | here> <backend-id>, /ci clear <target>` | CI routing: the CI backends from your config (GitHub Actions, GitLab.com shared runners, a self-hosted runner, VM runners still coming), whether each is usable now (token present yes/no, runners online, running/pending jobs, Actions per routed repo), and which repo or conversation uses which. A lead conversation or you set the route; each conversation sees its route in the status line and as a short note in its system prompt. | ||
scenarios | `/scenarios [S1..] [prompt\ | agent\ | workflow\ | frontmatter\ | env]` | Scénarios d'agents: presets of roles -> model, effort, unit (session, sub-conversation, Workflow agent(), Agent subagent) and max parallel (S1 Standard V6, S2 Économe, S3 Qualité max, S4 Nuit, plus yours from a JSON file). For each role, the exact settings (Agent call, agent() opts, agent-file frontmatter, env vars) and the documented limits, each with its doc URL and "non vérifié" where the docs say nothing; a Copy button for a ready-to-paste snippet. Warn-only toast when an Agent call names no model. |
guide | /mods-guide [onglet ou section] | Mode d'emploi des mods, in French: tabs (Sommaire, Bases, Événements, Interface, Configuration, Installer et activer, Tester, Écrire un mod pas à pas, Référence API, Nos mods, FAQ), foldable categories, and one card per mod of this repo built live from its plugin.json and hooks module. Same content in MODE-EMPLOI.md. |
factory and control read local files only (transcripts under ~/.claude/projects, the desktop app's session files, your settings) through a Python scanner (standard library) and draw the result. Dollar figures are estimates from token counts and the rate table in each mod's config.
What was kept from the earlier mods when this repo was set up (V6, Oct 2026). Only the keep rows are in this repo.
| Mod | Verdict | Why |
|---|---|---|
| factory | keep | The only view of the agent tree with models, efforts and cost. Next: feed it from hook events (agent.spawn, turn.start) instead of scanning files. |
| control | keep | The "nothing hidden" proof: every rule source, env variable and blocker in one place. Its metrics and practices still follow the V5 rules and are being rewritten for V6. |
| coordinator | merge into factory | Linked sub-conversations remove most lane collisions; only the "green PR not merged" and "waiting on you" rows are worth keeping, as a strip in factory. |
| context band (cc-control band, context-meter) | merge into a V6 band | Small and correct: context size against the hand-off threshold, read-only. Not written yet. |
| cc-control guards | drop | They refused and rewrote tool calls (send caps, spawn caps, rewrites). Blocking hooks are out. |
| ci | new (Oct 2026) | Which CI backend a repo or conversation should use, and whether it works right now, in one place instead of in rules text. |
| scenarios | new (Oct 2026) | Which model, effort and unit each role uses, with the doc that says so, instead of rules text that drifts. |
| guide | new (Oct 2026) | How mods work and how to write one, plus a card per mod that reads the mod itself. |
| cost-tracker, activity-pane, launchpad, night | drop | Wrong numbers for new model ids, superseded by factory, or built on timer wake-ups. |
| V4 pages and watcher | drop | Dead since early Oct, replaced by factory and the app's own agent view. |
Copy or symlink a mod folder to ~/.claude/mods/<name>:
git clone https://github.com/julian-passebecq/claude-mod.git
# copy
cp -r claude-mod/mods/factory ~/.claude/mods/factory
# or symlink (Windows: mklink /D, as admin or with developer mode)
ln -s "$PWD/claude-mod/mods/factory" ~/.claude/mods/factory
Then load it, either for every session with the environment (in ~/.claude/settings.json under env, folders separated by ; on Windows, : elsewhere):
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/factory;~/.claude/mods/control;~/.claude/mods/ci;~/.claude/mods/scenarios;~/.claude/mods/guide"
or as a dev-mod in the desktop app for one session. Python 3 must be on PATH (the scanners); gh / glab are used, when present, for PR/MR and CI state.
Each mod has a config file next to it (factory.config.json, config.json): rates, thresholds, colors, labels and paths are set there, nothing is hard-coded. factory also deep-merges ~/.claude/effort-board/factory.config.json if you have one. In control/config.json, set paths.projects_root (the folder that holds your repos) and the optional doc paths.
cp claude-mod/mods/ci/ci.config.example.json ~/.claude/ci.config.json # then fill the <PLACEHOLDERS>
python -I ~/.claude/mods/ci/scanner/ci.py show --check # same data as /ci, from any shell
python -I ~/.claude/mods/ci/scanner/ci.py route ~/code/my-repo gitlab-local --by lead
--config, then $CI_MOD_CONFIG, then ~/.claude/ci.config.json, then ci.config.json next to the mod, then the shipped example. Each backend has id, label, kind (github | gitlab), host, runner_tag, status (active | coming-soon | disabled) and notes; optional group, concurrent, minutes_month, public_only, check_runners, pending_projects. A value still in <angle brackets> counts as unset. Labels and colors are in labels.gh auth status, glab auth status, or the env variable names listed under hosts); a token value is never printed or stored. With a GitLab token: runners with the backend's tag online/offline and their running jobs (pending jobs for the projects in pending_projects). With gh: Actions availability for the repos routed to a GitHub backend. Missing tools or tokens show as unknown. Results are cached (status_cache, cache_minutes); /ci refresh checks now.routing_file (default ~/.claude/ci-routing.json), one entry per repo path or session id with backend, who set it and when. A session route wins over a repo route; a worktree inside a routed repo follows the repo. status_line and prompt_note in the config turn the status-line entry and the system-prompt note off.PATH; gh and glab are optional.deny, no rewriting of tool calls or prompts.ci also writes its routing file, on /ci route and /ci clear).gh / glab for PR state and the GitLab API for runner status (ci).python -I -m unittest discover -s mods/control/tests -p "test_*.py"
python -I -m unittest discover -s mods/ci/tests -p "test_*.py"
node --test mods/factory/tests/logic.test.mjs mods/scenarios/tests/logic.test.mjs mods/guide/tests/guide.test.mjs
node mods/guide/scripts/build.mjs # regenerate MODE-EMPLOI.md (table of contents, Nos mods cards)
# hook and pane tests, with the Claude Code test kit:
claude plugin test mods/factory
claude plugin test mods/control
claude plugin test mods/ci
claude plugin test mods/scenarios
claude plugin test mods/guide
# before every push (public repo): no private paths, tokens, e-mails or transcripts
python -I tools/confidentiality_scan.py .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is ignored since Claude Code 2.1.287 (mods are on by default, overview). GitHub Actions (.github/workflows/tests.yml) runs the Python and node tests and the confidentiality scan; the test-kit suites need the claude CLI and run locally.
control's reference-window test replays the author's own transcripts and only runs with CONTROL_REFERENCE=1.
MIT
hooks/register.tsx 208 lines1// ci mod: which CI backends exist, whether they are usable, and which repo or conversation uses which.
2// /ci backends + status + the route for this conversation, opens the CI routing pane
3// /ci refresh the same after a fresh status check
4// /ci route <target> <id> writes the routing file (target: a repo path, a session id, `here` or `session`)
5// /ci clear <target>
6// Everything runs through scanner/ci.py (standard library). The mod shows and informs: it never denies,
7// blocks or rewrites a tool call. Its only write is the routing file, on /ci route, /ci clear or a Clear button.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Snapshot } from '../types'
12import { PANE, checkLine, mark, parseArgs, parseSnapshot, promptNote, resolveTarget, routeLine, rowColor, statusText, summary, usableText } from './view'
13
14const snapshot = atom({ plugin: 'ci', key: 'snapshot' } as const, '')
15const checking = atom({ plugin: 'ci', key: 'checking' } as const, false)
16const error = atom({ plugin: 'ci', key: 'error' } as const, '')
17
18const root = ($: EngineInterface) => $.plugin.root.replace(/\\/g, '/')
19const safe = async <T,>(work: Promise<T>, fallback: T): Promise<T> => {
20 try {
21 return await work
22 } catch {
23 return fallback
24 }
25}
26
27/** Runs `python -I ci.py <args>` and parses its JSON; `{ error }` on any failure, never a throw. */
28const ci = async ($: EngineInterface, args: string[], timeoutMs = 20000): Promise<{ ok: true; json: any; text: string } | { ok: false; error: string }> => {
29 try {
30 const r = await $.process.run(['python', '-I', `${root($)}/scanner/ci.py`, ...args], { timeoutMs })
31 let json: any
32 try {
33 json = JSON.parse(r.stdout)
34 } catch {
35 json = undefined
36 }
37 if (r.exitCode !== 0 || !json) return { ok: false, error: json?.error ?? (r.stderr.trim().split('\n').pop() || `exit ${r.exitCode}`) }
38 return { ok: true, json, text: r.stdout }
39 } catch (err) {
40 return { ok: false, error: String(err) }
41 }
42}
43
44const where = async ($: EngineInterface) => ({
45 cwd: (await safe($.session.cwd(), '')).replace(/\\/g, '/'),
46 id: await safe($.session.id(), ''),
47})
48
49/** `show` for this conversation; `check` runs the network status check when the cache is stale, `force` always. */
50const refresh = async ($: EngineInterface, mode: 'cache' | 'check' | 'force' = 'cache'): Promise<Snapshot | undefined> => {
51 const { cwd, id } = await where($)
52 const args = ['show', '--cwd', cwd, ...(id ? ['--session', id] : [])]
53 if (mode === 'check') args.push('--check')
54 if (mode === 'force') args.push('--refresh')
55 if (mode !== 'cache') await update($, checking, () => true)
56 const r = await ci($, args, mode === 'cache' ? 20000 : 120000)
57 if (mode !== 'cache') await update($, checking, () => false)
58 if (!r.ok) {
59 await update($, error, () => r.error)
60 return undefined
61 }
62 await update($, error, () => '')
63 await update($, snapshot, () => r.text)
64 const s = r.json as Snapshot
65 if (s.options?.status_line !== false) $.ui.status(s.current ? `CI: ${s.current.backend}` : undefined)
66 return s
67}
68
69// One background check at a time per module copy; it runs on a timer so it outlives the command.
70let busy = false
71const checkSoon = ($: EngineInterface, force = false) => {
72 if (busy) return
73 busy = true
74 $.clock.after(0, async () => {
75 try {
76 await refresh($, force ? 'force' : 'check')
77 } finally {
78 busy = false
79 }
80 })
81}
82
83export const register: Register = on => {
84 on('session.start', async ($, e, next) => {
85 await $.command.register({
86 name: 'ci',
87 description: 'CI routing: backends, their status, and which repo or conversation uses which (route / clear)',
88 argumentHint: '[refresh] | route <repo|session|here> <backend-id> | clear <repo|session|here>',
89 })
90 const ran = await next(e)
91 // the route for the status line and the prompt note; cache only, no network at start
92 $.clock.after(0, () => void refresh($, 'cache'))
93 return ran
94 })
95
96 on('command.run', { command: 'ci' }, async ($, e) => {
97 const p = parseArgs(e.args ?? '')
98 if (p.kind === 'usage') return { text: p.text }
99 const { cwd, id } = await where($)
100 if (p.kind === 'route' || p.kind === 'clear') {
101 const target = resolveTarget(p.target, cwd, id)
102 const args = p.kind === 'route'
103 ? ['route', target, p.backend, '--by', id ? `session ${id}` : 'session', '--cwd', cwd]
104 : ['clear', target, '--cwd', cwd]
105 const r = await ci($, args)
106 if (!r.ok) return { text: `ci: ${r.error}` }
107 await refresh($, 'cache')
108 return {
109 text: p.kind === 'route'
110 ? `ci: ${r.json.kind} ${r.json.key} → ${r.json.backend}${r.json.warning ? ` (${r.json.warning})` : ''}`
111 : `ci: cleared ${r.json.removed} route(s) for ${r.json.key}`,
112 }
113 }
114 await $.ui.open({ id: PANE.id, title: PANE.title })
115 const s = await refresh($, 'cache')
116 if (!s) return { text: `ci: ${await read($, error)}` }
117 if (p.refresh || s.stale) checkSoon($, p.refresh)
118 return { text: summary(p.refresh ? { ...s, stale: true } : s) }
119 })
120
121 // The route as information for the model; nothing is blocked or rewritten.
122 on('prompt.compose', async ($, e, next) => {
123 const result = await next(e)
124 const s = parseSnapshot(await read($, snapshot))
125 const note = s && s.options?.prompt_note !== false ? promptNote(s) : undefined
126 if (!note) return result
127 return { ...result, sections: [...result.sections, { id: 'ci:route', text: note, scope: 'session' as const }] }
128 })
129
130 on('ui.render', { component: 'Pane', requestId: PANE.id }, async ($, e) => {
131 const { Box, Text, Button } = $.ui.resolve(e)
132 const s = parseSnapshot(await read($, snapshot))
133 const isChecking = await read($, checking)
134 const problem = await read($, error)
135 const head = (
136 <Box flexDirection="row" gap={1}>
137 <Text dimColor>
138 {isChecking ? 'checking…' : `checked ${s?.generated ?? 'never'}`}
139 {problem ? ` · ci.py failed: ${problem}` : ''}
140 </Text>
141 <Button key="refresh" hotkey="r" plain dimColor onPress={() => checkSoon($, true)}>
142 Refresh
143 </Button>
144 </Box>
145 )
146 if (!s) {
147 return (
148 <Box flexDirection="column">
149 {head}
150 <Text>No data yet: run /ci or press Refresh.</Text>
151 </Box>
152 )
153 }
154 const L = s.labels
155 const cur = s.current
156 return (
157 <Box flexDirection="column">
158 {head}
159 <Text bold>
160 {L.here ?? 'This conversation'}: {cur ? `${cur.backend} (${cur.match} route)` : (L.no_route ?? 'no route')}
161 </Text>
162 <Text> </Text>
163 <Text bold>{L.backends ?? 'Backends'}</Text>
164 {s.backends.map(b => (
165 <Box flexDirection="column">
166 <Text>
167 <Text color={rowColor(L, b)}>{mark(b)} </Text>
168 <Text bold>{b.label}</Text>
169 <Text dimColor>
170 {' '}· {b.id} · {b.kind} · {b.host}
171 {b.runner_tag ? ` · tag ${b.runner_tag}` : ''} · {statusText(L, b.status)}
172 </Text>
173 {b.status === 'active' ? <Text color={rowColor(L, b)}> · {usableText(L, b.check?.usable)}</Text> : null}
174 </Text>
175 <Text dimColor wrap="wrap">{' '}{checkLine(b)}</Text>
176 {b.notes ? <Text dimColor wrap="wrap">{' '}{b.notes}</Text> : null}
177 </Box>
178 ))}
179 <Text> </Text>
180 <Text bold>
181 {L.routes ?? 'Routes'} ({s.routes.length}) · {s.routing_file}
182 </Text>
183 {s.routes.length === 0 ? <Text dimColor>none: /ci route here {'<backend-id>'}</Text> : null}
184 {s.routes.map(r => (
185 <Box flexDirection="row" gap={1}>
186 <Text wrap="wrap" bold={cur?.key === r.key}>
187 {cur?.key === r.key ? '▸ ' : ' '}
188 {routeLine(s, r)}
189 </Text>
190 <Button
191 key={`clear-${r.key}`}
192 plain
193 dimColor
194 onPress={async () => {
195 await ci($, ['clear', r.key])
196 await refresh($, 'cache')
197 }}
198 >
199 Clear
200 </Button>
201 </Box>
202 ))}
203 <Text dimColor>Set a route: /ci route {'<repo-path|session-id|here|session> <backend-id>'} · config {s.config}</Text>
204 </Box>
205 )
206 })
207}
208hooks/view.ts 107 lines1// Pure helpers of the ci mod: argument parsing and text. No `$`, so tests call them directly.
2import type { Backend, Labels, Route, Snapshot, Tri } from '../types'
3
4export const PANE = { id: 'ci-routing', title: 'CI routing' } as const
5
6export type Parsed =
7 | { kind: 'show'; refresh: boolean }
8 | { kind: 'route'; target: string; backend: string }
9 | { kind: 'clear'; target: string }
10 | { kind: 'usage'; text: string }
11
12export const USAGE = 'usage: /ci [refresh] · /ci route <repo-path|session-id|here|session> <backend-id> · /ci clear <repo-path|session-id|here|session>'
13
14/** `/ci`, `/ci refresh`, `/ci route <target> <backend>`, `/ci clear <target>`. Quotes keep a path with spaces whole. */
15export const parseArgs = (args: string): Parsed => {
16 const words = (args.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, ''))
17 const [verb = '', ...rest] = words
18 const v = verb.toLowerCase()
19 if (v === '' || v === 'show' || v === 'status') return { kind: 'show', refresh: false }
20 if (v === 'refresh' || v === 'check') return { kind: 'show', refresh: true }
21 if (v === 'route' || v === 'set') {
22 if (rest.length !== 2) return { kind: 'usage', text: USAGE }
23 return { kind: 'route', target: rest[0]!, backend: rest[1]! }
24 }
25 if (v === 'clear' || v === 'unset') {
26 if (rest.length !== 1) return { kind: 'usage', text: USAGE }
27 return { kind: 'clear', target: rest[0]! }
28 }
29 return { kind: 'usage', text: USAGE }
30}
31
32/** `here`/`repo` -> the session's directory, `session`/`this` -> the session id, anything else as typed. */
33export const resolveTarget = (target: string, cwd: string, sessionId: string): string => {
34 const t = target.toLowerCase()
35 if (t === 'here' || t === 'repo' || t === '.') return cwd
36 if (t === 'session' || t === 'this') return sessionId
37 return target
38}
39
40export const usableText = (labels: Labels, u: Tri | undefined): string => labels.usable?.[u ?? 'unknown'] ?? u ?? 'unknown'
41export const statusText = (labels: Labels, s: string): string => labels.status?.[s] ?? s
42
43/** Theme color of a backend row: its usability when active, its status otherwise. */
44export const rowColor = (labels: Labels, b: Backend): string => {
45 const key = b.status !== 'active' ? b.status : (b.check?.usable ?? 'unknown')
46 const fallback: Record<string, string> = { yes: 'success', no: 'error', unknown: 'subtle' }
47 return labels.colors?.[key] ?? fallback[key] ?? 'subtle'
48}
49
50export const mark = (b: Backend): string =>
51 b.status !== 'active' ? '·' : b.check?.usable === 'yes' ? '●' : b.check?.usable === 'no' ? '○' : '?'
52
53const n = (x: number | null | undefined) => (x == null ? '?' : String(x))
54
55/** One line of facts about a backend's check: token, runners, jobs, why. */
56export const checkLine = (b: Backend): string => {
57 const c = b.check
58 if (b.status !== 'active') return b.status
59 if (!c) return 'not checked yet'
60 const parts = [`token ${c.token.present}${c.token.source ? ` (${c.token.source})` : ''}`]
61 if (c.runners) parts.push(`runners ${c.runners.online} online / ${c.runners.total}`)
62 if (c.jobs) parts.push(`jobs ${n(c.jobs.running)} running, ${n(c.jobs.pending)} pending`)
63 for (const a of c.actions) parts.push(`${a.repo}: actions ${a.actions} (${a.why})`)
64 parts.push(c.why)
65 return parts.join(' · ')
66}
67
68export const routeLine = (s: Snapshot, r: Route): string => {
69 const b = s.backends.find(x => x.id === r.backend)
70 return `${r.kind} ${r.key} → ${b?.label ?? r.backend} (${r.backend}) · set by ${r.set_by} · ${r.set_at}`
71}
72
73/** The text /ci answers with. */
74export const summary = (s: Snapshot): string => {
75 const L = s.labels
76 const lines = [`${L.title ?? 'CI routing'} · checked ${s.generated ?? 'never'}${s.stale ? ' (refreshing)' : ''}`]
77 for (const b of s.backends) lines.push(`${mark(b)} ${b.id} · ${b.label} · ${statusText(L, b.status)} · ${b.status === 'active' ? usableText(L, b.check?.usable) : ''}${b.status === 'active' ? ' · ' : ''}${checkLine(b)}`.replace(/ · $/, ''))
78 const cur = s.current
79 lines.push(
80 cur
81 ? `${L.here ?? 'This conversation'}: ${cur.backend} (${cur.match} route ${cur.key}, set by ${cur.set_by} ${cur.set_at})`
82 : `${L.here ?? 'This conversation'}: ${L.no_route ?? 'no route'}`,
83 )
84 lines.push(`${s.routes.length} route(s) in ${s.routing_file}`)
85 return lines.join('\n')
86}
87
88/** The system-prompt note: the route as information, never an instruction to block anything. */
89export const promptNote = (s: Snapshot): string | undefined => {
90 const cur = s.current
91 if (!cur) return undefined
92 const b = s.backends.find(x => x.id === cur.backend)
93 const where = b ? `${b.label} (${b.kind}, ${b.host}${b.runner_tag ? `, runner tag ${b.runner_tag}` : ''})` : cur.backend
94 return `CI route (ci mod): this ${cur.match === 'session' ? 'conversation' : 'repo'} uses CI backend "${cur.backend}": ${where}. ` +
95 `Set by ${cur.set_by} at ${cur.set_at}. Use it for this work's pipelines unless told otherwise; /ci shows all backends and their status.`
96}
97
98export const parseSnapshot = (text: string): Snapshot | undefined => {
99 if (!text) return undefined
100 try {
101 const s = JSON.parse(text) as Snapshot
102 return Array.isArray(s.backends) ? s : undefined
103 } catch {
104 return undefined
105 }
106}
107types/index.d.ts 62 lines1// Shapes of the JSON that scanner/ci.py prints (`show`) and the `$.state` values of the ci mod.
2
3export type Tri = 'yes' | 'no' | 'unknown'
4export type Check = {
5 id: string
6 /** presence only, never a value: source is 'env:NAME', 'gh', 'glab' or '' */
7 token: { present: Tri; source: string }
8 runners: { online: number; offline: number; total: number } | null
9 jobs: { running: number | null; pending: number | null } | null
10 actions: { repo: string; actions: Tri; why: string }[]
11 usable: Tri
12 why: string
13}
14export type Backend = {
15 id: string
16 label: string
17 kind: 'github' | 'gitlab'
18 host: string
19 runner_tag: string
20 status: 'active' | 'coming-soon' | 'disabled'
21 notes: string
22 group?: string
23 concurrent?: number | string
24 minutes_month?: number
25 check: Check | null
26}
27export type Route = { key: string; kind: 'repo' | 'session'; backend: string; set_by: string; set_at: string }
28export type Labels = {
29 title?: string
30 backends?: string
31 routes?: string
32 here?: string
33 no_route?: string
34 usable?: Partial<Record<Tri, string>>
35 status?: Record<string, string>
36 colors?: Record<string, string>
37}
38export type Snapshot = {
39 generated: string | null
40 stale: boolean
41 config: string
42 routing_file: string
43 labels: Labels
44 options: { status_line: boolean; prompt_note: boolean }
45 backends: Backend[]
46 routes: Route[]
47 current: (Route & { match: 'repo' | 'session' }) | null
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 ci: {
53 /** last `ci.py show` output as JSON text ('' before the first read) */
54 snapshot: string
55 /** a status check (network) is running */
56 checking: boolean
57 /** last error line from ci.py, '' when fine */
58 error: string
59 }
60 }
61}
62