The mentor framework for Claude Code: a self-contained process layer with per-session modes, a work-item lifecycle with a hard Definition of Done, budget-aware…

Credo (Latin: "I believe") is the mentor framework for Claude Code: a self-contained process layer that governs how a whole session runs. It turns a loose set of good habits into an enforced, project-local workflow: a per-session working mode, a work-item lifecycle with a hard Definition of Done, budget-aware autonomy, visual verification, and safety rules that travel into every subagent.
It is opinionated by design and stands alone. Two external touchpoints are documented honestly below: the limit plugin (recommended prerequisite for a few features) and ntfy (optional push notifications). Everything else lives inside this plugin.
credo is built from small, composable pieces:
.credo/items/. The folder the file lives in is the single source of truth for its status. A hard Definition of Done gate controls promotion to done.| Command | Description |
|---|---|
/credo:psalm | Interactive guide to available topics and workflows |
/credo:setup | Interactive setup wizard: install plugins, sync instructions, init project |
/credo:migrate | Migrate an existing repo into the .credo/ structure |
/credo:project | Pin the target repo for credo's project layer (hub-aware), or show the resolved target |
/credo:session-init | Load the main-agent delegation-first workflow instructions |
/credo:session-active | Set the session mode to active |
/credo:session-passive | Set the session mode to passive |
/credo:session-autonomous | Set the session mode to autonomous |
/credo:role-task | Set this session's default role to task/build (owns implementing GO items incl. commits/push per dogma) |
/credo:role-plan | Set this session's default role to plan/clarify (owns clarifying 1_clarify items, no commits/push) |
/credo:role-clear | Clear this session's default role (back to no role; the agent does everything) |
/credo:sandbox-promote | Promote an accepted sandbox artifact from .credo/sandbox-tmp/ to .credo/sandbox/ |
/credo:explain | Explain something in depth (what/why/example/consequences) |
/credo:optimize | Run the opt-in optimisation audit for this repo (read-only scan, findings offered one by one) |
/credo:disable | Disable credo for this directory (silence onboarding and the [credo] line here, reversible) |
/credo:enable | Enable credo for this directory (opt in; overrides a previous decline) |
/credo:self-restart | Restart this session and resume exactly the same session in the same profile (e.g. to load plugin updates); check (dry run), run --user-confirmed / run --announce 300 (autonomous only) [--update], cancel, status |
Skills and hooks are auto-discovered by Claude Code from the skills/ and hooks/ directories, so they are not hand-listed in the manifest.
The mode is per session and set with a command. It is stored on disk keyed by the session id, so it survives compaction, new sessions, and subagents. The UserPromptSubmit hook re-injects a one-line reminder of the active mode on every prompt, and names the matching skill to load. A second hook injects the current local date and time (mode-independent) - on every prompt via UserPromptSubmit, and additionally on PostToolUse (throttled + delta-guarded) so the clock stays fresh during long autonomous runs instead of freezing at the last prompt. This keeps the agent date/time-aware: when no mode is set it proposes a fitting presence mode via Ask (never autonomous, never silently), and it mentions the active mode in normal output now and then, especially after a long gap since the last prompt. An autonomous run is never interrupted by a mode-change question.
A third UserPromptSubmit hook (credo-skill-nudge.sh) re-surfaces a single low-cadence self-assess reminder, because the SessionStart knowledge list ages out over a long session and the credo skills get underused. It never forces a skill: the agent is asked to judge by effort and risk and actively use the fitting credo skill (diag / verify / audit / items / requirements-verbatim) on non-trivial or complex work, and skip it on small or trivial changes. It fires in all credo modes. Toggle with CREDO_SKILL_NUDGE (default true) and set the cadence with CREDO_SKILL_NUDGE_EVERY (default 5); the fire slot is offset by half the window from credo-attended-reminder.sh so the two reminders never stack in the same turn.
A SessionStart hook makes the session credo-aware. Until the session has made a credo decision, and only on a human-present (re)start (startup/clear), it asks - via Ask - whether to use the credo workflow (yes runs /credo:session-init, no records a marker so it never asks again); it never asks in autonomous work, nor on compact/resume/fork. Once credo is active, the same hook re-injects the full credo command + skill list on every start (including after a compact, when the model context is gone), tagged by execution class so it is clear which commands the agent may run itself, which need the user, and which it must never trigger autonomously. The activation ASK can be turned off on its own with CREDO_SESSION_START_ASK=false (the whole hook with CREDO_SESSION_START_INJECT=false), and when the task backend is gsd the workflow text stays silent, since GSD is then the task system rather than credo. The same hook always appends a compact user-shorthand legend (see Chat shorthands) - in every state, including declined or /credo:disabled directories and the gsd backend.
/credo:session-active) - intensive live collaboration with the user at the keyboard. Progress is logged via the limit thresholds and compact-plus, open GO items are picked up alongside, clarifications happen during subagent waits. No keep-alive./credo:session-passive) - the agent carries most of the work while the user is reachable only for clarifications. Every item is pushed toward a full GO; less is more, so only genuinely ambiguous items go back to the user. No keep-alive./credo:session-autonomous) - approved GO items are worked unattended. Keep-alive is hook-enforced: a registered Stop hook blocks a stop that has no scheduled ScheduleWakeup and instructs the model to set one (loop-safe, and inert outside autonomy); a registered UserPromptSubmit hook turns autonomy off on any real user message. Budget caps are enforced, ntfy sends immediate come-to-PC pushes (questions, blockers, completion) plus a mandatory content-rich progress digest on a fixed interval, and progress is secured via compact-plus.Each command sets the mode and loads its skill. The three session skills share one canonical common core (defined in the session-active skill) and layer their mode-specific rules on top.
Orthogonal to the mode, a session can carry a persistent default role that scopes which part of the item lifecycle it owns. Like the mode, the role is stored on disk keyed by the session id, so it survives compaction and new prompts.
/credo:role-plan) - owns clarifying 1_clarify items; it does not commit or push./credo:role-task) - owns implementing GO items, including commits and push per dogma./credo:role-clear) - clears the role, back to no role, so the agent does everything.credo teaches the agent a few short chat shorthands, so you can type them without adding anything to your own CLAUDE.md. The SessionStart hook injects a small legend on every start (startup, resume, clear, compact, fork), so it survives compaction. It is pure user-intent parsing, not workflow: it applies in every directory - credo active or not, hub directories, directories without .credo/, and directories silenced via /credo:disable. Each shorthand refers to what precedes it and has a general meaning first, plus a credo-item mapping when an item is named.
| Shorthand | Meaning |
|---|---|
dd | Done. <thing> dd = that thing is done; bare dd = the last discussed or requested thing is done; cc-up dd = the update is done. On a single Definition of Done point, dd ticks only that point, never the whole item. For a credo item (#123 dd) the agent runs the normal Definition of Done gate (audit, plus verify for ui: true) and on a pass moves it with credo-item-move.sh 123 done. The shorthand is your statement, never a gate bypass: if the gate fails, the agent reports instead of moving. |
vf | Verified or verify, by context. In a manual test round where you were asked to check something, vf means you checked it and it passes. The scope is exactly what it refers to: on a single Definition of Done point only that point is ticked as verified and the item stays where it is; only when the whole item was under test (#123 vf) does it move to 3_verified/ via credo-item-move.sh 123 verified --user-authorized (main agent only). Otherwise it is an instruction to verify for real with runtime proof, not a code review (in credo: the verify skill, via subagents). If it is unclear which is meant, the agent asks briefly. |
cf | Start or continue a clarify round: structured questions until the open points are resolved. In credo: the 1_clarify items, one item per Ask round, or the named one (#57 cf). |
go / bk / pk / ar | Item moves, valid only right after an item ref (#57 go); bare they are ordinary words. #N go = your GO approval: the agent adds a (GO: <your words>) line to the item History and moves it to 2_go if the GO entry gate (G1-G6) passes, otherwise reports why not. #N bk = block it: the agent asks for the concrete blocker if you did not name one (blocked_by is mandatory) and moves it to 3_blocked. #N pk = park on hold, #N pk future = park for later. #N ar = archive. All moves go through credo-item-move.sh. |
??? | Explain the thing it follows (or the last thing, when alone) in depth: What / Why / Example / Consequences. Same behavior as /credo:explain. |
cc-up | You fully updated Claude Code (plugins and marketplaces fetched and installed, /reload-plugins, full quit and restart, possibly resumed). The running state is current; the agent takes this at face value and never asks for update steps or proof. Also valid when mentioned in passing. |
cm | Commit, following the repo's commit rules. No push. |
ph | Commit and push, following the repo's rules (push only where they allow it). |
exclude / excluded | Always the local .git/info/exclude, never .gitignore. Only ignore / ignored / gitignore means .gitignore. |
#N vs §cct_N | #N only for real items (credo items, issues, PRs, tickets). Claude's harness tasks and any other numbering are §cct_N (cct = Claude Code task, e.g. §cct_2), so the two never get confused. §cct_2 dd = harness task 2 done. |
| Test/question letters | Every manual test and every question to the user gets a letter from one continuous sequence shared by both (A..Z across replies, wrap to A after Z), under a visible heading ### 🧪 B) <topic> (test, numbered steps, 1-2 items per round) or ### ❓ Y) <topic> (question). Answer with B vf, B2 vf (step 2 of B) or Y: .... Each reply ends with the open letters in bold in a language-neutral footer (🧪: C, D · ❓: Y, #177), so credo's band reads it in any conversation language; the next free letter survives a compact via the handoff. |
Turn off only the legend with CREDO_SESSION_START_SHORTHANDS=false; CREDO_SESSION_START_INJECT=false silences the whole hook, legend included.
.credo/ structurescripts/credo-init.sh creates a per-project .credo/ tree in the target repo (idempotent) and adds the git-exclude lines so .credo/** is not committed by default (except RULES.md, see below). The layout:
.credo/
docs/ stable "how we work here" conventions
screenshots/ visual-verify evidence: <task>-<viewport>-<YYYY-MM-DD>.png (a PostToolUse hook files screenshots here automatically, even from a hub)
items/
1_todo/{1_clarify,2_go,3_blocked}
2_done/
3_verified/ human-authorized; agent files here only on your explicit instruction
4_archived/
parked/{hold,future}
process/
requirements/ append-only verbatim log
handoffs/ rolling HANDOFF.md plus handoffs/archive/
reports/ diag / audit / verification reports
checklists/ auto-generated cross-cutting checklists
config per-project config (YAML)
id-counter deterministic integer counter
RULES.md per-repo special rules (grants); versioned by default
.credo/** is deliberately kept out of git, with ONE exception: RULES.md (per-repo special rules) is versioned by default, because those rules are meant to travel with the repo. Everything else's persistence across a compact is disk plus your normal backups, not commits.
Opt-in versioning (per project). The default (all of .credo/ excluded except RULES.md) is right for solo or private work. If you want the items and process visible in the team's history, run credo-init.sh with CREDO_VERSION_TRACKED=1: it then versions .credo/ in the repo except the per-project config and the screenshots/, which stay local always. The exclude entries are kept in a marker-delimited managed block in .git/info/exclude, so re-running switches the mode cleanly in either direction (drop the variable to go back to fully unversioned). This is a deliberate per-project decision; the default is unversioned.
Config is YAML, merged lowest to highest:
builtin (templates/config.default.yaml) < global (~/.claude/credo/config) < profile ($CLAUDE_CONFIG_DIR/credo/config) < project (.credo/config)
The builtin template ships universal, safe-for-everyone defaults (viewports 320/768/1440, timing windows, the compact thresholds 80/92, the budget schedule, wakeup offsets). On first need the global config is created from this template. Personal and environment-specific fields (ntfy topic, commit-identity hint, WSL reachability, living-docs list) are intentionally left empty and are filled just-in-time by the skill that needs them, with permission per change. /credo:setup is an optional way to pre-initialize this.
The profile layer sits between global and project: $CLAUDE_CONFIG_DIR/credo/config (for example ~/.claude-private/credo/config) lets a second Claude Code profile override the shared global per key, while every key it does not set still falls back to global. It is optional and never auto-created; for the default profile it equals global and is skipped. Session state (modes, decisions, project pins) and the autonomy flags likewise follow the active profile, so two profiles run side by side without sharing state.
Item ids come from scripts/credo-id-next.sh. The counter file holds the last id given out; allocation is atomic (flock): read the counter, scan the items tree, take max(counter, highest existing id) + 1, write it back, print it. The counter, not the folder, decides the number - deleting the highest item never lowers the next id, so a deleted id is never reused; the folder scan is only a safety floor that lifts a counter which fell behind the items on disk (merge, clone, backup restore, sync) and warns on stderr when it does. Always take an id from the helper; never hand-pick one.
A work item is one Markdown file (templates/item.template.md). Its frontmatter is lean and mandatory: id, title, created, type (bug | optimization | feature | question | chore), and ui (true means a visual verify is part of the Definition of Done). The optional audit: full forces the full audit tier (see Audit depth). There is no status field, because the folder is the status.
The lifecycle, moving the file with scripts/credo-item-move.sh:
items/1_todo/1_clarify/) - requirement captured verbatim, success criteria drafted.items/1_todo/2_go/) - the user gave an explicit GO; ready to build. Entry is gated (G1-G6): only a fully clarified, GO'd item with no open build-details or unbuilt-item dependency may enter. Once here it IS buildable by definition (go=go): the building agent never self-skips or self-demotes it for size, UI, or "not sure it is verifiable". A stale-looking body is likewise never a reason to self-skip or demote a 2_go item (body-freshness invariant). The one sanctioned way back is the Named-Decision-Test: a genuine user-only decision surfacing mid-build sends the item to 1_clarify marked URGENT - never "too big / too hard".items/1_todo/3_blocked/) - GO'd but hard-blocked by another, unbuilt credo item (structured blocked_by/blocks relations). Auto-returns to 2_go when the blocker is done. Distinct from parked/hold, which is for an external dependency. "Too big / too hard" is never a block.items/2_done/) - built and wired, and the Definition of Done gate has passed. A move to done (or verified / archived) also cleans up merged and clean worktrees per DOGMA-PERMISSIONS (see "Parallel work: worktrees").items/3_verified/) - human-authorized. The agent never self-verifies, but may perform the mechanical move on your explicit instruction (credo-item-move.sh <id> verified --user-authorized). Raw mv/git mv of item files is blocked and redirected to the move helper (enforced by credo-item-move-guard.sh).Parked work lives under items/parked/{hold,future}; abandoned work under items/4_archived/.
scripts/credo-item-counts.sh [--json] prints the item count per status folder of the resolved project (read-only, exit 4 when no credo project resolves), so any renderer can show live counts without knowing the folder layout.
scripts/credo-item-list.sh [--json] [--per N] prints, per status folder, the total plus the newest N items (default 15) as id and title (frontmatter title:, else the slug); same project resolution and exit codes. scripts/credo-session-status.sh [--json] [session_id] prints one session's mode, role and autonomy state (running, paused, next wake) from the per-session state files (read-only, exit 2 when no session id resolves).
An item may move to 2_done/ only when:
ui: true, a visual verify has driven the real surface in a browser across the configured viewports and captured screenshot evidence,The audit gate always runs, by a dedicated subagent that is not the builder; only its depth follows risk. full (every audit check, current verify evidence) applies to ui: true items, security-relevant work (permissions, allow/block hooks, secrets, deletion, installs), writes outside the repo, data migration, large scope (guide value: more than ~10 files or a new component), items with the optional frontmatter audit: full, and anything in doubt. lean applies to everything else: the diff against each DoD point, wiring of new code, docs current for the change, and stale claims inside the item - still with severity-ranked findings and a verdict. The main agent picks the tier and the report states it with a one-line reason. There is no audit: lean override, so a risky item is never downgraded. Builder and audit run dogma's relevant test stage when the repo defines one; the full suite runs where dogma places it (for example once per release bundle), not per item. Several lean items may share one audit subagent with one verdict each; full items are always audited singly.
Auto-discovered under skills/. Each auto-triggers when it applies, including inside subagents.
2_done/. Proposes a severity-ranked decision (BLOCKER/MAJOR/MINOR/NIT), never a fix.verify.local_bringup; otherwise the visual verify defers as human-only.1_clarify item blocked by a knowledge gap (a missing measurement, a mockup, or a feasibility proof), done under .credo/sandbox-tmp/ without touching production code and without git, so the task/build agent is never disturbed. An accepted artifact is promoted to .credo/sandbox/ via /credo:sandbox-promote..credo/RULES.md: project-local grants that widen credo's autonomy for that one repo (for example "restarting local services is always allowed without asking" in a debug-only repo). Loaded and honored every session and inside subagents, versioned so they travel with the repo, resolved via the project layer (works from a hub). Grants can only widen latitude within the safety floor (precedence: safety > DOGMA-PERMISSIONS > RULES.md > defaults); set one any time by just asking.hooks/band.tsx 606 lines1// credo band: an optional Claude Code mod that shows credo's state above the
2// prompt. It only READS state through credo's core scripts and renders it; the
3// core works the same without it (and in harnesses without mods).
4//
5// Lines: item counts per status, session mode/role + open test/question letters
6// + controls, and (only while this session runs autonomously) the 5h figure,
7// the next ladder rung and the next wake. One pane shows items or the
8// shorthand cheatsheet; the prompt hint lists the shorthands the band hides.
9// While a self-restart of THIS session is pending (scripts/credo-self-restart.py
10// marker), a blinking notice with a countdown tops the band and one standing toast names the time.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register, RenderChildren } from 'claude-code'
14
15import type { CredoCounts, CredoItemList, CredoRestart, CredoSession, CredoShorthand } from '../types'
16import { parseLetters } from './letters'
17import { parseMarker, restartNotice, restartToast } from './self-restart-marker'
18import type { RestartMarker } from './self-restart-marker'
19
20const HIGHLIGHT = '#d946ef'
21const NEW_COLOR = 'whiteBright'
22const HIGHLIGHT_MS = 6000
23const BLINK_MS = 500
24const REFRESH_MS = 10000
25const RUN_TIMEOUT_MS = 5000
26
27const counts = atom({ plugin: 'credo', key: 'counts' } as const, null)
28const isExpanded = atom({ plugin: 'credo', key: 'isExpanded' } as const, false)
29const changed = atom({ plugin: 'credo', key: 'changed' } as const, [])
30const created = atom({ plugin: 'credo', key: 'created' } as const, [])
31const blink = atom({ plugin: 'credo', key: 'blink' } as const, false)
32const session = atom({ plugin: 'credo', key: 'session' } as const, { mode: null, role: null, paused: false })
33// blink phase of the mode/role tag when an autonomous run gets paused or re-armed
34const sessionBlink = atom({ plugin: 'credo', key: 'sessionBlink' } as const, false)
35const letters = atom({ plugin: 'credo', key: 'letters' } as const, { tests: [], questions: [] })
36const auto = atom({ plugin: 'credo', key: 'auto' } as const, { running: false, wake: null, five: null, ladder: [] })
37const itemList = atom({ plugin: 'credo', key: 'itemList' } as const, null)
38const shorthands = atom({ plugin: 'credo', key: 'shorthands' } as const, [])
39// pending self-restart of this session: the notice text and its blink phase
40const restart = atom({ plugin: 'credo', key: 'restart' } as const, null)
41const restartBlink = atom({ plugin: 'credo', key: 'restartBlink' } as const, false)
42// yellow warning sign before the notice (dogma's block uses the no-entry sign)
43const RESTART_ICON = '⚠'
44// while a restart is pending: tick (blink phase + marker stat) every 500 ms and show
45// ONE static toast per pending restart that stands until the restart (a new one only
46// when the schedule or reason changes); otherwise only a slow stat of the marker
47const RESTART_TICK_MS = 500
48const RESTART_IDLE_MS = 3000
49const RESTART_CANCEL_TOAST_MS = 5000
50
51// e rotates these presets, each hiding a bit more. The dogma band reads this
52// value ({ plugin: 'credo', key: 'preset' }) and hides itself at 'open only'.
53const preset = atom({ plugin: 'credo', key: 'preset' } as const, 0)
54const PRESETS = [
55 { name: 'all', groups: [0, 1, 2], dogma: true },
56 { name: 'no parked', groups: [0, 1], dogma: true },
57 { name: 'open + dogma', groups: [0], dogma: true },
58 { name: 'open only', groups: [0], dogma: false },
59]
60const presetOf = (v: number) => PRESETS[v % PRESETS.length] ?? PRESETS[0]!
61
62type Key = keyof CredoCounts | 'parked'
63type Status = { key: Key; short: string; name: string; color: string }
64
65// groups split by color: open work, finished, parked
66const GROUPS: Status[][] = [
67 [
68 { key: 'clarify', short: 'cf', name: 'Clarify', color: 'yellow' },
69 { key: 'go', short: 'go', name: 'Go', color: 'yellow' },
70 { key: 'blocked', short: 'bk', name: 'Blocked', color: 'red' },
71 ],
72 [
73 { key: 'done', short: 'dd', name: 'Done', color: 'green' },
74 { key: 'verified', short: 'vf', name: 'Verified', color: 'green' },
75 ],
76 [
77 { key: 'parked', short: 'pk', name: 'Parked', color: 'gray' },
78 { key: 'archived', short: 'ar', name: 'Archived', color: 'gray' },
79 ],
80]
81const ALL = GROUPS.flat()
82
83// item pane sections, in display order, keyed like credo-item-list.sh
84const PANE_STATUSES = [
85 { key: 'clarify', name: 'Clarify', short: 'cf', color: 'yellow' },
86 { key: 'go', name: 'Go', short: 'go', color: 'yellow' },
87 { key: 'blocked', name: 'Blocked', short: 'bk', color: 'red' },
88 { key: 'done', name: 'Done', short: 'dd', color: 'green' },
89 { key: 'verified', name: 'Verified', short: 'vf', color: 'green' },
90 { key: 'hold', name: 'Hold', short: 'pk', color: 'gray' },
91 { key: 'future', name: 'Future', short: 'pk future', color: 'gray' },
92 { key: 'archived', name: 'Archived', short: 'ar', color: 'gray' },
93]
94const PER_STATUS = 15
95
96// shorthands the prompt hint always lists (the band never shows them)
97const GENERAL_SHORTHANDS = ['???', 'cm', 'ph']
98// without an item system: cf / dd / vf still work on their own (clarify round,
99// done, verified/verify); only the #N item moves (go bk pk ar) need items
100const ITEMLESS_SHORTHANDS = ['cf', 'dd', 'vf', ...GENERAL_SHORTHANDS]
101
102// one pane for both views, so at most one is ever open
103const PANE = 'credo-panel'
104const panelView = atom({ plugin: 'credo', key: 'panelView' } as const, 'items')
105
106const HEAD = '◆ credo'
107const GAP = 4
108const HEAD_GAP = 2
109const SAME_COLOR_GAP = 2
110
111type Piece = { width: number; node: RenderChildren; gap?: number }
112
113// greedy line packing: each piece stays whole, a line never exceeds columns
114function pack(pieces: Piece[], columns: number, gap: number): Piece[][] {
115 let line: Piece[] = []
116 const lines: Piece[][] = [line]
117 let used = 0
118 for (const p of pieces) {
119 const need = (line.length ? (p.gap ?? gap) : 0) + p.width
120 if (line.length && used + need > columns) {
121 line = [p]
122 lines.push(line)
123 used = p.width
124 } else {
125 line.push(p)
126 used += need
127 }
128 }
129 return lines
130}
131
132function value(c: CredoCounts, key: Key) {
133 return key === 'parked' ? c.hold + c.future : c[key]
134}
135
136// runs one of credo's core scripts with this session's id (session pin, autonomy)
137async function runScript($: EngineInterface, args: string[]) {
138 const sid = await $.session.id()
139 return $.process.run([`${$.plugin.root}/scripts/${args[0]}`, ...args.slice(1)], {
140 env: { CREDO_SESSION_ID: sid },
141 timeoutMs: RUN_TIMEOUT_MS,
142 })
143}
144
145// counts; exit 4 (no credo project) hides the band. Any other failure (a
146// timeout under load, e.g. around a compact) keeps the last known value instead
147// of blanking the band, and logs the reason to the debug log.
148async function refresh($: EngineInterface) {
149 let next: CredoCounts | null = null
150 try {
151 const r = await runScript($, ['credo-item-counts.sh', '--json'])
152 if (r.exitCode !== 0 && r.exitCode !== 4) {
153 $.ui.log(`credo band: credo-item-counts.sh exit ${r.exitCode}: ${r.stderr.trim()}`, { to: 'debug' })
154 return
155 }
156 next = r.exitCode === 0 ? JSON.parse(r.stdout) : null
157 } catch (err) {
158 $.ui.log(`credo band: credo-item-counts.sh failed: ${String(err)}`, { to: 'debug' })
159 return
160 }
161 const prev = await read($, counts)
162 await update($, counts, () => next)
163 if (prev === null || next === null) return
164
165 const diff = ALL.filter(s => value(prev, s.key) !== value(next, s.key))
166 if (diff.length === 0) return
167 // nothing went down -> the increases are new items (white); otherwise moves (fuchsia)
168 const isNew = diff.every(s => value(next, s.key) > value(prev, s.key))
169 const text = diff.map(s => `${s.short} ${value(prev, s.key)}→${value(next, s.key)}`).join(' · ')
170 $.ui.toast(`credo: ${isNew ? 'new ' : ''}${text}`)
171 if (isNew) await update($, created, () => diff.map(s => s.key))
172 else await update($, changed, () => diff.map(s => s.key))
173 // blink: alternate highlight and normal color every BLINK_MS
174 for (let t = 0; t < HIGHLIGHT_MS; t += BLINK_MS) {
175 await update($, blink, v => !v)
176 await $.clock.sleep(BLINK_MS)
177 }
178 await update($, blink, () => false)
179 await update($, changed, () => [])
180 await update($, created, () => [])
181}
182
183// paused or re-armed: the mode/role tag blinks like an item move, plus a toast
184async function flashSession($: EngineInterface, paused: boolean) {
185 $.ui.toast(paused ? 'credo: autonomous run paused' : 'credo: autonomous run re-armed')
186 for (let t = 0; t < HIGHLIGHT_MS; t += BLINK_MS) {
187 await update($, sessionBlink, v => !v)
188 await $.clock.sleep(BLINK_MS)
189 }
190 await update($, sessionBlink, () => false)
191}
192
193// mode, role and autonomy from credo-session-status.sh; while autonomy runs,
194// also the 5h figure (credo-budget-read.sh) and the ladder rungs (credo-config.sh)
195async function refreshStatus($: EngineInterface) {
196 let status: {
197 mode: string | null
198 role: string | null
199 autonomy: { running: boolean; paused: boolean; wake_scheduled: number | null }
200 }
201 try {
202 const r = await runScript($, ['credo-session-status.sh', '--json'])
203 if (r.exitCode !== 0) throw new Error(`exit ${r.exitCode}: ${r.stderr.trim()}`)
204 status = JSON.parse(r.stdout)
205 } catch (err) {
206 // keep the last known mode/role/autonomy rather than blanking the line
207 $.ui.log(`credo band: credo-session-status.sh failed: ${String(err)}`, { to: 'debug' })
208 return
209 }
210 // credo sets the paused flag for every active/passive session too; it only
211 // matters while the mode is autonomous (a user message paused the run)
212 const paused = status.mode === 'autonomous' && status.autonomy.paused === true
213 const prev = await read($, session)
214 const nextSession: CredoSession = { mode: status.mode, role: status.role, paused }
215 await update($, session, () => nextSession)
216 if ((prev.paused ?? false) !== paused) void flashSession($, paused)
217
218 if (!status.autonomy.running) {
219 await update($, auto, () => ({ running: false, wake: null, five: null, ladder: [] }))
220 return
221 }
222 let five: number | null = null
223 let ladder: number[] = []
224 try {
225 const b = await runScript($, ['credo-budget-read.sh', '--json'])
226 if (b.exitCode === 0) five = JSON.parse(b.stdout).five_hour.utilization
227 const l = await runScript($, ['credo-config.sh', 'get', 'budget.autonomous_5h.main_ladder'])
228 if (l.exitCode === 0) ladder = l.stdout.split('\n').map(Number).filter(n => n > 0)
229 } catch {
230 // keep what we have
231 }
232 const wake = status.autonomy.wake_scheduled
233 await update($, auto, () => ({ running: true, wake, five, ladder }))
234}
235
236// newest items per status from credo-item-list.sh, for the item pane
237async function refreshItems($: EngineInterface) {
238 let next: CredoItemList | null = null
239 try {
240 const r = await runScript($, ['credo-item-list.sh', '--json', '--per', String(PER_STATUS)])
241 next = r.exitCode === 0 ? JSON.parse(r.stdout) : null
242 } catch {
243 next = null
244 }
245 await update($, itemList, () => next)
246}
247
248// shorthand legend for the cheatsheet and the hint (templates/shorthands.json)
249async function loadShorthands($: EngineInterface) {
250 let next: CredoShorthand[] = []
251 try {
252 next = JSON.parse(await $.fs.read(`${$.plugin.root}/templates/shorthands.json`))
253 } catch {
254 next = []
255 }
256 await update($, shorthands, () => next)
257}
258
259// self-restart marker watch: the marker is only read when its mtime changed
260let restartMarker: RestartMarker | null = null
261let restartMtime = -1
262let restartTicks = 0
263let restartToastKey: string | null = null
264let restartTimer: (() => void) | null = null
265let restartFast = false
266
267async function restartMarkerPath($: EngineInterface) {
268 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
269 return `${dir}/credo/self-restart.json`
270}
271
272// one tick: stat (and on change read) the marker, publish the notice, blink and toast.
273// Never throws: a missing or unreadable marker simply means no notice.
274async function restartTick($: EngineInterface, path: string, sid: string) {
275 let notice: CredoRestart | null = null
276 try {
277 try {
278 const st = await $.fs.stat(path)
279 if (st.mtimeMs !== restartMtime) {
280 restartMtime = st.mtimeMs
281 restartMarker = parseMarker(await $.fs.read(path))
282 }
283 } catch {
284 restartMarker = null
285 restartMtime = -1
286 }
287 notice = restartNotice(restartMarker, sid, await $.clock.now())
288 const prev = await read($, restart)
289 if ((prev?.text ?? null) !== (notice?.text ?? null)) await update($, restart, () => notice)
290 if (notice) {
291 restartTicks += 1
292 await update($, restartBlink, v => !v)
293 const toast = restartToast(restartMarker, sid, await $.clock.now())
294 if (toast && toast.key !== restartToastKey) {
295 restartToastKey = toast.key
296 $.ui.toast(`${RESTART_ICON} ${toast.text}`, { timeoutMs: toast.timeoutMs })
297 }
298 } else if (restartTicks !== 0) {
299 restartTicks = 0
300 await update($, restartBlink, () => false)
301 // the standing toast cannot be withdrawn, so a cancel says so in a short one
302 if (restartToastKey !== null && restartMarker?.status === 'cancelled') {
303 $.ui.toast('credo: self-restart cancelled', { timeoutMs: RESTART_CANCEL_TOAST_MS })
304 }
305 restartToastKey = null
306 }
307 } catch (err) {
308 $.ui.log(`credo band: self-restart notice failed: ${String(err)}`, { to: 'debug' })
309 }
310 // fast while a notice shows, slow otherwise
311 if ((notice !== null) !== restartFast) watchRestart($, path, sid, notice !== null)
312}
313
314function watchRestart($: EngineInterface, path: string, sid: string, fast: boolean) {
315 restartTimer?.()
316 restartFast = fast
317 restartTimer = $.clock.every(fast ? RESTART_TICK_MS : RESTART_IDLE_MS, () => void restartTick($, path, sid)).cancel
318}
319
320async function refreshAll($: EngineInterface) {
321 void refresh($)
322 void refreshStatus($)
323 if ((await $.ui.panes()).some(p => p.id === PANE)) void refreshItems($)
324}
325
326async function openPanel($: EngineInterface, view: 'items' | 'help') {
327 await update($, panelView, () => view)
328 if (view === 'items') await refreshItems($)
329 await $.ui.open({ id: PANE, title: 'credo' })
330}
331
332export const register: Register = on => {
333 on('session.start', async ($, e, next) => {
334 const started = await next(e)
335 await $.command.register({ name: 'credo-items', description: 'Show credo items per status in a pane' })
336 await loadShorthands($)
337 await refresh($)
338 await refreshStatus($)
339 $.clock.every(REFRESH_MS, () => void refreshAll($))
340 try {
341 const path = await restartMarkerPath($)
342 const sid = await $.session.id()
343 watchRestart($, path, sid, false)
344 void restartTick($, path, sid)
345 } catch (err) {
346 $.ui.log(`credo band: self-restart watch not started: ${String(err)}`, { to: 'debug' })
347 }
348 return started
349 })
350
351 on('tool.call', async ($, e, next) => {
352 const result = await next(e)
353 if (e.tool === 'Bash') void refreshAll($)
354 return result
355 }).catch(($, e, next) => next(e))
356
357 on('turn.complete', async ($, e, next) => {
358 void refreshAll($)
359 // the main loop's answer ends with the open letters (credo verify convention)
360 if (e.agentId === undefined && e.answer) {
361 const parsed = parseLetters(e.answer)
362 await update($, letters, () => parsed)
363 }
364 return next(e)
365 })
366
367 // under the prompt: only the shorthands the band does not already show
368 // (statuses hidden by the preset, plus the general ones)
369 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
370 // without an item system (no project, declined dir, no mode) the shorthands
371 // that need no items still apply, like credo's SessionStart legend
372 const hasItems = (await read($, counts)) !== null
373 const current = presetOf(await read($, preset))
374 const hidden = GROUPS.filter((_, gi) => !current.groups.includes(gi))
375 .flat()
376 .map(s => s.short)
377 // the band's long-form toggle (l) also spells out a one-word meaning here
378 const long = await read($, isExpanded)
379 const legend = await read($, shorthands)
380 const word = (k: string) => legend.find(s => s.key === k || s.key === `#N ${k}`)?.word ?? '?'
381 const keys = hasItems ? [...hidden, ...GENERAL_SHORTHANDS] : ITEMLESS_SHORTHANDS
382 const tail = `Shortcuts: ${keys.map(k => (long ? `${k}(${word(k)})` : k)).join(' ')}`
383 return next({ ...e, props: { ...e.props, tail } })
384 })
385
386 // /credo-items opens the item pane too
387 on('command.run', { command: 'credo-items' }, async $ => {
388 await openPanel($, 'items')
389 return { text: 'credo items pane opened.' }
390 })
391
392 // the shared pane: item titles per status (newest first) or the cheatsheet
393 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
394 const { Box, Text } = $.ui.resolve(e)
395 if ((await read($, panelView)) === 'help') {
396 // without an item system the #N item shorthands do not apply
397 const hasItems = (await read($, counts)) !== null
398 const legend = (await read($, shorthands)).filter(s => hasItems || !s.key.startsWith('#N '))
399 const width = Math.max(0, ...legend.map(s => s.key.length))
400 return (
401 <Box flexDirection="column">
402 {legend.map(s => (
403 <Box key={s.key}>
404 <Text bold color="cyan">{s.key.padEnd(width + 2)}</Text>
405 <Text wrap="truncate-end">{s.meaning}</Text>
406 </Box>
407 ))}
408 </Box>
409 )
410 }
411 const list = await read($, itemList)
412 if (list === null) return <Text dimColor>No credo project for this session.</Text>
413 const sections: RenderChildren[] = []
414 for (const s of PANE_STATUSES) {
415 const st = list.statuses.find(x => x.key === s.key)
416 const total = st?.total ?? 0
417 const shown = (st?.items ?? []).map(i => `#${i.id} ${i.title}`)
418 sections.push(
419 <Box key={s.key} flexDirection="column" marginBottom={1}>
420 <Text bold color={s.color}>
421 {s.name}({s.short}): {total}
422 </Text>
423 {shown.map(t => (
424 <Text key={t} wrap="truncate-end"> {t}</Text>
425 ))}
426 {total > shown.length ? <Text dimColor> … {total - shown.length} more</Text> : null}
427 </Box>,
428 )
429 }
430 return <Box flexDirection="column">{sections}</Box>
431 })
432
433 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
434 const below = await next(e)
435 const c = await read($, counts)
436 if (e.props.hasSurvey) return below
437 const { Box, Button, Text } = $.ui.resolve(e)
438
439 // pending self-restart of this session: yellow sign, blinking fuchsia text
440 const pendingRestart = await read($, restart)
441 const isRestartOn = await read($, restartBlink)
442 const restartRow = pendingRestart ? (
443 <Box key="restart" columnGap={1}>
444 <Text bold color="yellow">{RESTART_ICON}</Text>
445 <Text bold color={HIGHLIGHT} dimColor={!isRestartOn} wrap="truncate-end">{pendingRestart.text}</Text>
446 </Box>
447 ) : null
448 // without a credo project (no item system) the counts and the item controls
449 // are left out; mode/role, open letters and autonomy are session state and
450 // still show, as long as there is any
451 const hasItems = c !== null
452
453 const long = await read($, isExpanded)
454 // highlighted only in the "on" phase of the blink
455 const isOn = await read($, blink)
456 const moved = isOn ? await read($, changed) : []
457 const fresh = isOn ? await read($, created) : []
458
459 const color = (s: Status, n: number) =>
460 moved.includes(s.key)
461 ? HIGHLIGHT
462 : fresh.includes(s.key)
463 ? NEW_COLOR
464 : s.key === 'blocked' && n > 0
465 ? 'red'
466 : s.color
467
468 // one cell per status, statusline style: short "go: 1245", long "Go(go): 1245"
469 const chipWidth = (s: Status) => {
470 const n = c === null ? 1 : String(value(c, s.key)).length
471 return long ? s.name.length + s.short.length + 4 + n : s.short.length + 2 + n
472 }
473
474 const current = presetOf(await read($, preset))
475 const shown = GROUPS.filter((_, gi) => current.groups.includes(gi)).flat()
476
477 const chip = (s: Status) => {
478 const n = c === null ? 0 : value(c, s.key)
479 const isZero = n === 0 && !moved.includes(s.key) && !fresh.includes(s.key)
480 return (
481 <Box key={s.key}>
482 {long ? (
483 <Text>
484 <Text color={isZero ? undefined : color(s, n)} dimColor={isZero}>{s.name}</Text>
485 <Text dimColor>({s.short}): </Text>
486 </Text>
487 ) : (
488 <Text dimColor>{s.short}: </Text>
489 )}
490 <Text bold={!isZero} dimColor={isZero} color={isZero ? undefined : color(s, n)}>{n}</Text>
491 </Box>
492 )
493 }
494
495 // 2 spaces within a color group (cf go, dd vf, pk ar), 4 where the color changes
496 const pieces: Piece[] = (hasItems ? shown : []).map((s, i) => ({
497 width: chipWidth(s),
498 node: chip(s),
499 gap: i > 0 && shown[i - 1]?.color === s.color ? SAME_COLOR_GAP : GAP,
500 }))
501 // second line: session mode/role, open letters, then the controls
502 const meta: Piece[] = []
503
504 // third line, only while this session runs autonomously: 5h now, the next ladder rung, next wake
505 const autoLine: Piece[] = []
506 const a = await read($, auto)
507 if (a.running) {
508 const nextRung = a.five === null ? null : a.ladder.find(r => r > (a.five ?? 0)) ?? null
509 const hard = a.ladder[3] ?? null
510 const danger = a.five !== null && hard !== null && a.five >= hard
511 const wakeAt = a.wake ? new Date(a.wake * 1000).toTimeString().slice(0, 5) : null
512 const parts = [
513 a.five === null ? '5h ?' : `5h ${a.five}%${nextRung === null ? '' : `→${nextRung}`}`,
514 wakeAt ? `wake ${wakeAt}` : '',
515 ].filter(Boolean)
516 const text = parts.join(' ')
517 autoLine.push({
518 width: 7 + text.length,
519 node: (
520 <Text key="auto">
521 <Text bold color="magenta">⟳ auto </Text>
522 <Text color={danger ? 'red' : 'magenta'}>{text}</Text>
523 </Text>
524 ),
525 })
526 }
527 const ses = await read($, session)
528 const modeTag = ses.mode && ses.paused ? `${ses.mode} paused` : ses.mode
529 const tags = [modeTag, ses.role].filter((x): x is string => x !== null).join('/')
530 const sesColor = (await read($, sessionBlink)) ? HIGHLIGHT : 'cyan'
531 if (tags) meta.push({ width: tags.length, node: <Text key="ses" color={sesColor}>{tags}</Text> })
532
533 // open test and question letters, so nothing waiting on the user gets lost
534 const open = await read($, letters)
535 if (open.tests.length) {
536 const text = open.tests.join(' ')
537 meta.push({ width: 3 + text.length, node: <Text key="tests"><Text>🧪 </Text><Text bold color="blue">{text}</Text></Text> })
538 }
539 if (open.questions.length) {
540 const text = open.questions.join(' ')
541 meta.push({ width: 3 + text.length, node: <Text key="questions"><Text>❓ </Text><Text bold color="blue">{text}</Text></Text> })
542 }
543
544 if (!hasItems && meta.length === 0 && autoLine.length === 0)
545 return restartRow ? (
546 <Box flexDirection="column">
547 {restartRow}
548 {below}
549 </Box>
550 ) : below
551
552 if (hasItems)
553 meta.push({
554 width: 10,
555 node: <Button key="items" hotkey="i" plain dimColor label="☰ items" onPress={() => openPanel($, 'items')} />,
556 })
557 meta.push({
558 width: 9,
559 node: <Button key="help" hotkey="h" plain dimColor label="? help" onPress={() => openPanel($, 'help')} />,
560 })
561 if (hasItems)
562 meta.push({
563 width: 4,
564 node: <Button key="form" hotkey="l" plain dimColor label="⇆" onPress={() => update($, isExpanded, v => !v)} />,
565 })
566 if (hasItems)
567 meta.push({
568 width: 4 + current.name.length,
569 node: (
570 <Button
571 key="preset"
572 hotkey="e"
573 plain
574 dimColor
575 label={`◐ ${current.name}`}
576 onPress={() => update($, preset, v => (v + 1) % PRESETS.length)}
577 />
578 ),
579 })
580
581 // wrap by hand against the band's real width, so the band knows its height;
582 // the counts first (head on the first line), then the meta line, both indented alike
583 const room = e.props.bodyColumns - HEAD.length - HEAD_GAP - 1
584 const lines = [...(pieces.length ? pack(pieces, room, GAP) : []), ...pack(meta, room, GAP), ...(autoLine.length ? pack(autoLine, room, GAP) : [])]
585 const mine = (
586 <Box flexDirection="column">
587 {lines.map((line, li) => (
588 <Box key={`l${li}`} columnGap={HEAD_GAP}>
589 {li === 0 ? <Text bold color="cyan">{HEAD}</Text> : <Text>{' '.repeat(HEAD.length)}</Text>}
590 <Box>{line.flatMap((p, i) => (i ? [<Text key={`gap${i}`}>{' '.repeat(p.gap ?? GAP)}</Text>, p.node] : [p.node]))}</Box>
591 </Box>
592 ))}
593 </Box>
594 )
595
596 // credo on top; another band (dogma) below
597 return (
598 <Box flexDirection="column">
599 {restartRow}
600 {mine}
601 {below}
602 </Box>
603 )
604 })
605}
606hooks/letters.ts 54 lines1// Pure parser for the open-letters footer of an answer (credo verify convention).
2// Canonical form is language-neutral, so the band works in any conversation language:
3// "**<T>: C, D** · **<Q>: Y, #177**" with <T> = U+1F9EA and <Q> = U+2753 or U+2754
4// (bold optional, U+FE0F optional, space before the colon allowed). Legacy word labels are still read for older answers: English
5// "Open for testing:" / "Open questions:" and German "Offen zum Testen:" /
6// "Offene Fragen:". No engine imports, so it can be checked on its own
7// (scripts/test-credo-band-letters.sh).
8//
9// Defensive: a footer is often not the clean letter list the convention asks for.
10// A pure code list keeps every code (letters like B, h2, Task-I, item refs #N,
11// harness refs §cct_N). Prose ("Remaining notes in x.md (#177, #113)") keeps only
12// item/harness refs and uppercase letter codes, so plain words never show up.
13
14import type { CredoLetters } from '../types'
15
16// emoji label first (canonical), then the legacy word labels
17const TEST_LABELS = '\\u{1F9EA}\\uFE0F?|Open for testing|Offen zum Testen|Offene Tests'
18const QUESTION_LABELS = '[\\u{2753}\\u{2754}]\\uFE0F?|Open questions|Offene Fragen'
19
20// a code in a pure list: #N, §cct_N, short letter codes (B, h2, IJ3) or hyphen codes (Task-I, Qb-2)
21const LIST_CODE = /^(?:#\d+|§cct_\d+|[A-Za-z]{1,3}\d{0,3}|[A-Za-z]+-[A-Za-z0-9]{1,3})$/
22// what survives inside prose: refs and uppercase letter codes only
23const PROSE_CODE = /^(?:#\d+|§cct_\d+|[A-Z]{1,2}\d{0,3}|[A-Z][A-Za-z]*-[A-Za-z0-9]{1,3})$/
24const NONE = /^(?:none|keine|-+|n\/a)$/i
25
26// text after the LAST "<label>:" up to the end of the footer part. The label must be
27// followed by a colon (bold may close before it), so a heading like "### <T> B) x"
28// never counts as a footer. A fullwidth colon (CJK input) counts as a colon.
29function segment(answer: string, labels: string): string | null {
30 const re = new RegExp(`(?:${labels})(?:\\*\\*)?\\s*[::]\\s*(?:\\*\\*)?([^\\n]*)`, 'giu')
31 let last: string | null = null
32 for (const m of answer.matchAll(re)) last = m[1]
33 if (last === null) return null
34 return last.split(/\*\*|·|\|| - /)[0]
35}
36
37function codes(seg: string | null): string[] {
38 if (!seg) return []
39 const tokens = seg
40 .split(/[,;\s()[\]]+/)
41 .map(x => x.replace(/^[*`'"]+|[*`'".:!?]+$/g, ''))
42 .filter(x => x && !NONE.test(x))
43 const pure = tokens.every(x => LIST_CODE.test(x))
44 const kept = pure ? tokens : tokens.filter(x => PROSE_CODE.test(x))
45 return [...new Set(kept)]
46}
47
48export function parseLetters(answer: string): CredoLetters {
49 return {
50 tests: codes(segment(answer, TEST_LABELS)),
51 questions: codes(segment(answer, QUESTION_LABELS)),
52 }
53}
54hooks/self-restart-marker.ts 94 lines1// Pure helpers for the self-restart notice of credo's band (band.tsx): parse
2// the marker written by scripts/credo-self-restart.py, decide whether this
3// session shows the notice, and format the countdown. No engine imports, so
4// they can be checked on their own.
5//
6// Marker: ${CLAUDE_CONFIG_DIR:-~/.claude}/credo/self-restart.json with
7// session_id, started, reason, status (pending | cancelled | stopping |
8// updating | relaunched | failed: ...), scheduled (ISO 8601 with offset), ...
9
10import type { CredoRestart } from '../types'
11
12export type RestartMarker = {
13 sessionId: string
14 status: string
15 reason: string
16 scheduledMs: number | null
17}
18
19// statuses during which this session is still waiting to be stopped
20const VISIBLE = ['pending', 'stopping']
21
22export function parseMarker(text: string): RestartMarker | null {
23 let raw: unknown
24 try {
25 raw = JSON.parse(text)
26 } catch {
27 return null
28 }
29 if (raw === null || typeof raw !== 'object') return null
30 const m = raw as Record<string, unknown>
31 if (typeof m.session_id !== 'string' || typeof m.status !== 'string') return null
32 const parsed = typeof m.scheduled === 'string' ? Date.parse(m.scheduled) : NaN
33 return {
34 sessionId: m.session_id,
35 status: m.status,
36 reason: typeof m.reason === 'string' ? m.reason : '',
37 scheduledMs: Number.isFinite(parsed) ? parsed : null,
38 }
39}
40
41// shown only for this session and only while the restart is still ahead
42export function isVisible(marker: RestartMarker | null, sessionId: string): boolean {
43 return marker !== null && sessionId !== '' && marker.sessionId === sessionId && VISIBLE.includes(marker.status)
44}
45
46// remaining time as m:ss (h:mm:ss from one hour); null without a schedule
47export function formatCountdown(scheduledMs: number | null, nowMs: number): string | null {
48 if (scheduledMs === null) return null
49 const total = Math.max(0, Math.ceil((scheduledMs - nowMs) / 1000))
50 const h = Math.floor(total / 3600)
51 const m = Math.floor((total % 3600) / 60)
52 const s = String(total % 60).padStart(2, '0')
53 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
54}
55
56// the notice for this session, or null when nothing is to be shown
57export function restartNotice(marker: RestartMarker | null, sessionId: string, nowMs: number): CredoRestart | null {
58 if (!isVisible(marker, sessionId) || marker === null) return null
59 const when =
60 marker.status === 'stopping' ? 'now' : (() => {
61 const left = formatCountdown(marker.scheduledMs, nowMs)
62 return left === null ? 'pending' : `in ${left}`
63 })()
64 const reason = marker.reason ? ` (reason: ${marker.reason})` : ''
65 const cancel = marker.status === 'pending' ? ' - cancel: credo-self-restart.py cancel' : ''
66 return { text: `credo self-restart ${when}${reason}${cancel}` }
67}
68
69// how long the toast outlives the scheduled stop: covers the stop, the update and
70// the relaunch; the old TUI goes away with the restart anyway
71const TOAST_GRACE_MS = 2 * 60 * 1000
72
73export type RestartToast = { key: string; text: string; timeoutMs: number }
74
75// ONE static toast per pending restart (no countdown, so it never needs refreshing):
76// the band shows it once per key and lets it stand until the restart
77export function restartToast(marker: RestartMarker | null, sessionId: string, nowMs: number): RestartToast | null {
78 if (!isVisible(marker, sessionId) || marker === null) return null
79 const pad = (n: number) => String(n).padStart(2, '0')
80 const at = marker.scheduledMs === null ? null : new Date(marker.scheduledMs)
81 const when =
82 marker.status === 'stopping' ? 'now'
83 : at === null ? 'pending'
84 : `at ${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
85 const reason = marker.reason ? ` (reason: ${marker.reason})` : ''
86 const cancel = marker.status === 'pending' ? ' - cancel: credo-self-restart.py cancel' : ''
87 const left = marker.scheduledMs === null ? 0 : Math.max(0, marker.scheduledMs - nowMs)
88 return {
89 key: `${marker.sessionId}|${marker.scheduledMs ?? ''}|${marker.reason}`,
90 text: `credo self-restart ${when}${reason}${cancel}`,
91 timeoutMs: left + TOAST_GRACE_MS,
92 }
93}
94types/index.d.ts 59 lines1// State contract of credo's optional Claude Code band (hooks/band.tsx).
2// Every value is a read-only mirror of what credo's core scripts print; the
3// band keeps no state of its own beyond view toggles and highlights.
4
5/** scripts/credo-item-counts.sh --json (credo_dir omitted) */
6export type CredoCounts = {
7 clarify: number
8 go: number
9 blocked: number
10 done: number
11 verified: number
12 archived: number
13 hold: number
14 future: number
15}
16
17/** mode and role from scripts/credo-session-status.sh --json; paused only while mode is autonomous */
18export type CredoSession = { mode: string | null; role: string | null; paused: boolean }
19
20/** open test / question letters parsed from the last main-loop answer */
21export type CredoLetters = { tests: string[]; questions: string[] }
22
23/** autonomy of this session plus the 5h figure and the ladder rungs */
24export type CredoAuto = { running: boolean; wake: number | null; five: number | null; ladder: number[] }
25
26/** scripts/credo-item-list.sh --json */
27export type CredoItemList = {
28 credo_dir: string
29 statuses: { key: string; folder: string; total: number; items: { id: string; title: string }[] }[]
30}
31
32/** this session's pending self-restart (scripts/credo-self-restart.py marker), as shown by the band and the toast */
33export type CredoRestart = { text: string }
34
35/** one entry of templates/shorthands.json */
36export type CredoShorthand = { key: string; word: string; meaning: string }
37
38declare module 'claude-code' {
39 interface PluginState {
40 credo: {
41 counts: CredoCounts | null
42 isExpanded: boolean
43 changed: string[]
44 created: string[]
45 blink: boolean
46 preset: number
47 session: CredoSession
48 sessionBlink: boolean
49 letters: CredoLetters
50 panelView: string
51 auto: CredoAuto
52 itemList: CredoItemList | null
53 shorthands: CredoShorthand[]
54 restart: CredoRestart | null
55 restartBlink: boolean
56 }
57 }
58}
59