SLOPSHOPPER

credo

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…

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · credo
│ ┃ credo ✕ › fix the failing auth test and add an audit log call │ ┃ No credo project for this session. │ ● credo: credo band: credo-item-counts.sh failed: SyntaxError: JSON P │ ● credo: credo band: credo-session-status.sh failed: SyntaxError: JSO │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /credo-items │ ⎿ credo: credo items pane opened. │ ● credo: credo band: credo-item-counts.sh failed: SyntaxError: JSON P │ ● credo: credo band: credo-session-status.sh failed: SyntaxError: JSO │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · credo
No credo project for this session.
README

Credo

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.

What credo is

credo is built from small, composable pieces:

  • Session modes - every session runs in one of three modes (active, passive, autonomous). The mode is per session, stored on disk, and re-surfaced on every prompt.
  • Work-item lifecycle - each task is a Markdown file under .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.
  • Definition of Done - work counts as done only after a dedicated post-completion audit, plus a visual verify for anything with a UI surface.
  • Budget awareness - one place that governs how much of the 5-hour and weekly API limits may be spent, when to throttle, pause, wake up, or stop.
  • Visual verification - a UI change is proven by driving the real thing in a browser and measuring computed layout, not by a green test.
  • Safety - filesystem-protection and no-autonomous-installs rules, doubled into a skill so they apply inside subagents too.
  • Subagent self-sufficiency - every subagent is primed at start with the load-bearing rules, so delegated work stays correct even if the main agent context has drifted.

Commands

CommandDescription
/credo:psalmInteractive guide to available topics and workflows
/credo:setupInteractive setup wizard: install plugins, sync instructions, init project
/credo:migrateMigrate an existing repo into the .credo/ structure
/credo:projectPin the target repo for credo's project layer (hub-aware), or show the resolved target
/credo:session-initLoad the main-agent delegation-first workflow instructions
/credo:session-activeSet the session mode to active
/credo:session-passiveSet the session mode to passive
/credo:session-autonomousSet the session mode to autonomous
/credo:role-taskSet this session's default role to task/build (owns implementing GO items incl. commits/push per dogma)
/credo:role-planSet this session's default role to plan/clarify (owns clarifying 1_clarify items, no commits/push)
/credo:role-clearClear this session's default role (back to no role; the agent does everything)
/credo:sandbox-promotePromote an accepted sandbox artifact from .credo/sandbox-tmp/ to .credo/sandbox/
/credo:explainExplain something in depth (what/why/example/consequences)
/credo:optimizeRun the opt-in optimisation audit for this repo (read-only scan, findings offered one by one)
/credo:disableDisable credo for this directory (silence onboarding and the [credo] line here, reversible)
/credo:enableEnable credo for this directory (opt in; overrides a previous decline)
/credo:self-restartRestart 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.

Session modes

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.

  • active (/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.
  • passive (/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.
  • autonomous (/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.

Session roles

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.

  • plan/clarify (/credo:role-plan) - owns clarifying 1_clarify items; it does not commit or push.
  • task/build (/credo:role-task) - owns implementing GO items, including commits and push per dogma.
  • none (/credo:role-clear) - clears the role, back to no role, so the agent does everything.

Chat shorthands

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.

ShorthandMeaning
ddDone. <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.
vfVerified 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.
cfStart 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 / arItem 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-upYou 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.
cmCommit, following the repo's commit rules. No push.
phCommit and push, following the repo's rules (push only where they allow it).
exclude / excludedAlways 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 lettersEvery 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.

The .credo/ structure

scripts/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 cascade

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.

Deterministic id-counter

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.

The item workflow

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:

  1. clarify (items/1_todo/1_clarify/) - requirement captured verbatim, success criteria drafted.
  2. go (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".
  3. blocked (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.
  4. done (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").
  5. verified (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).

The Definition of Done gate

An item may move to 2_done/ only when:

  • the success criteria are observably met and the new code is actually wired in (a caller reaches it),
  • a dedicated audit subagent (not the builder) has reviewed the work against its stated requirement and Definition of Done and returned a pass,
  • for ui: true, a visual verify has driven the real surface in a browser across the configured viewports and captured screenshot evidence,
  • docs are updated in the same change.
Audit depth

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.

Building-block skills

Auto-discovered under skills/. Each auto-triggers when it applies, including inside subagents.

  • audit - read-only quality gate; reviews already-built work against its requirement and Definition of Done before it may move to 2_done/. Proposes a severity-ranked decision (BLOCKER/MAJOR/MINOR/NIT), never a fix.
  • diag - read-only root-cause diagnosis for a symptom; establishes the mechanism at file:line before any fix. The fix is a separate, GO-gated step.
  • verify - visual verification as the Definition of Done for any change with a runtime surface; proves behavior in a real browser with computed layout. A down surface may be brought up or restarted autonomously ONLY when the target is positively verified local (a process on this machine, not a deployed/remote/shared environment - judged by where it runs, not by the git branch), via verify.local_bringup; otherwise the visual verify defers as human-only.
  • pr-vetting - rigorous multi-subagent vetting of a pull request across technical, security, value/fit, and contributor-reputation dimensions; merges the findings into one decision-ready report while the merge/close decision stays with the maintainer.
  • issue-triage - selection-first GitHub issue triage: shortlist and prioritize before deep-triaging the chosen issues via parallel subagents, then recommend close/fix/keep/needs-info for the owner to approve before any action.
  • items - the work-item model where the folder is the status truth, gated by the Definition of Done.
  • sandbox - WRITE pre-work for a 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.
  • rules - per-repo special rules in .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.
  • optimize - the opt-in optimisation audit (see Optimisation audit): freshness check, read-only scan, report, and every finding offered as Implement / Later / Never.
  • requirements-verbatim - captures a requirement, decision, approval, or GO word-for-word into an append-only dated log so it survives compaction.
  • budget - the single source for API budget caps and reset rules across the 5-hour and weekly limits, plus the commit-identity gate before any commit. An autonomous start
Source 4 files
hooks/band.tsx 606 lines
1// 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}
606
hooks/letters.ts 54 lines
1// 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}
54
hooks/self-restart-marker.ts 94 lines
1// 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}
94
types/index.d.ts 59 lines
1// 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