SLOPSHOPPER

workflow-band

Shows the Document Workflow gate (workflow-cli status) in the band above the prompt.

newbandguardprocess
★ 9v0.1.0no licenseupdated 2026-10-09berlysia/dotfiles/mods/workflow-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workflow-band
› fix the failing auth test and add an audit log call ⏺ 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 WF workflow-cli status output not recognised: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
WF workflow-cli status output not recognised:
README

dotfiles

depends on chezmoi

How to use(for me)

  1. install chezmoi
  2. sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/bin
  3. winget install twpayne.chezmoi
  4. if you are in PowerShell, Set-ExecutionPolicy -ExecutionPolicy ByPass -Scope Process
  5. chezmoi init --apply berlysia

note

  • ~/.local/bin will be added to $PATH
  • ~/.local/.bin will be added to $PATH , and overwrite this directory with symlink

Tailscale経由のSSHと、公開リポジトリに端末情報を置かない鍵管理はSSH設定の手順を参照。

Local CI

Local workflow verification uses actrun, managed by this project's mise config.

mise install
pnpm ci:local:typescript
pnpm ci:local:codex
pnpm ci:local:shell

If your shell is not activated by mise, run commands through mise exec -- ... instead.

GitHub Environments

Claude workflows use environment-scoped secrets instead of repository-wide secrets.

Add CLAUDE_CODE_OAUTH_TOKEN to both environments. If you want unattended automation, leave approvals and wait timers disabled.

list of something will be set up

  • homebrew (only mac)
  • mise
  • direnv
  • fzf
  • ripgrep
  • bat
  • WSL2 ssh-agent (only WSL)
  • my custom zsh prompt

Claude Decision Log Analysis

This dotfiles project includes tools for analyzing Claude Code hook decision logs:

home/dot_claude/scripts/update-auto-approve.ts

Automated permission pattern analysis and management tool:

# Interactive review of all patterns
bun home/dot_claude/scripts/update-auto-approve.ts

# Preview changes without applying
bun home/dot_claude/scripts/update-auto-approve.ts --dry-run

# Analyze last 7 days only
bun home/dot_claude/scripts/update-auto-approve.ts --since 7d

# Auto-approve safe patterns without interaction
bun home/dot_claude/scripts/update-auto-approve.ts --auto-approve-safe

# Show detailed analysis information
bun home/dot_claude/scripts/update-auto-approve.ts --verbose

Features:

  • Analyzes ~/.claude/logs/decisions.jsonl for permission patterns
  • Provides risk scoring and confidence levels
  • Interactive review interface for pattern approval
  • Automatic backup of permission files
  • Supports time-based filtering of logs

Claude Code Configuration

This repository includes comprehensive Claude Code configuration with:

  • Custom skills (see .skills/ directory)
  • Global development guidelines (CLAUDE.md)
  • Development rules (debugging, external-review, TypeScript standards)

Settings.json Split Management

~/.claude/settings.json is managed by splitting it into multiple files:

  • .settings.base.json: Core settings (model, language, statusLine, alwaysThinkingEnabled, etc.)
  • .settings.permissions.json: Permission settings (allow/deny)
  • .settings.hooks.json.tmpl: Hooks configuration (dynamically generated with chezmoi variables)
  • .settings.plugins.json: Plugin configuration (enabledPlugins)

These files are automatically merged by run_onchange_update-settings-json.sh.tmpl during chezmoi apply.

Important notes:

  • To modify hooks configuration, edit .settings.hooks.json.tmpl and run chezmoi apply
  • Existing enabledPlugins settings are preserved (user manual changes are not overwritten)

~/.claude.json Automatic Management

~/.claude.json (MCP servers configuration) is automatically managed by run_onchange_update-claude-json.sh.tmpl:

How it works:

  • Reads MCP server versions from package.json dependencies
  • Merges template content with existing ~/.claude.json
  • Auto-updates mcpServers, preferredNotifChannel, and defaultMode
  • Creates backup before changes and shows diff

Managed MCP servers:

  • @mizchi/readability - Web page content extraction
  • chrome-devtools-mcp - Chrome DevTools automation
  • @playwright/mcp - Browser automation
  • @upstash/context7-mcp - Documentation search

To update versions: Edit package.json dependencies and run chezmoi apply

Dual-Layer Skills Management

This project uses a two-layer approach for managing Claude Code skills:

1. Handcrafted Skills (.skills/)
  • Stored in ${projectRoot}/.skills/ for unified management (at repo root, outside home/)
  • Synced to both ~/.claude/skills/ and ~/.codex/skills/ via run_after_sync-skills.sh.tmpl
  • Used by both Claude Code and Codex
  • Always preserved during updates
2. External Skills (home/dot_apm/apm.yml)
  • Declaratively managed in APM manifest (apm.yml)
  • Installed via apm install -g from GitHub repositories
  • Tracked via apm.lock.yaml
  • Auto-removed when deleted from manifest (handcrafted skills are protected)

Both layers coexist in ~/.claude/skills/ directory.

Plugin Management

Plugins are managed declaratively through .settings.plugins.json:

After chezmoi apply:

  • show-missing-plugins.sh detects missing plugins
  • Displays marketplace registration commands (if needed)
  • Shows plugin installation commands to run in Claude Code IDE

Syncing plugin changes back to dotfiles:

~/.claude/scripts/sync-enabled-plugins.sh

This exports current enabledPlugins from ~/.claude/settings.json to dotfiles for version control.

Using in Other Projects

To use these skills in another project:

cd /path/to/your/project
curl -fsSL https://raw.githubusercontent.com/berlysia/dotfiles/master/scripts/setup-claude-skills.sh | bash

This creates a simple .claude/settings.json with inline SessionStart command - no hook files needed!

Skills are installed to ~/.claude/ and auto-update on Claude Code startup.

See docs/external-usage.md for detailed documentation.

Skills Included

See .skills/ directory for available skills. Key skills include setup-claude-skills-for-web, commit-conventions, react-hooks, and logic-validation.

Source 4 files
hooks/register.tsx 118 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { WorkflowFailure, WorkflowSnapshot } from "../types";
5import { layoutBand } from "./layout";
6import { toSnapshot } from "./parse";
7
8const snapshot = atom(
9  { plugin: "workflow-band", key: "snapshot" } as const,
10  null,
11);
12
13const WORKFLOW_DOC_PATH = /\/\.tmp\/sessions\/[^/]+\//;
14
15/**
16 * Runs workflow-cli the way the Bash tool would: Claude Code hands its Bash
17 * children CLAUDE_PROJECT_DIR and CLAUDE_CODE_SESSION_ID, while
18 * `$.process.run` starts from the host process's own environment, which
19 * lacks them (or, in a claude started from another session's Bash, holds the
20 * parent's). Only those two are set here. DOCUMENT_WORKFLOW_DIR (a launch-time
21 * pin) and CLAUDE_TEST_CWD are left to pass through from the host, so an
22 * override the person started the session with keeps working.
23 */
24async function runWorkflowCli($: EngineInterface, args: string[]) {
25  const [root, id] = await Promise.all([$.session.root(), $.session.id()]);
26  return $.process.run(["workflow-cli", ...args], {
27    cwd: root,
28    env: { CLAUDE_PROJECT_DIR: root, CLAUDE_CODE_SESSION_ID: id },
29    timeoutMs: 5000,
30  });
31}
32
33async function readSnapshot(
34  $: EngineInterface,
35): Promise<WorkflowSnapshot | WorkflowFailure> {
36  try {
37    const [status, dir] = await Promise.all([
38      runWorkflowCli($, ["status"]),
39      runWorkflowCli($, ["dir"]),
40    ]);
41    return toSnapshot(status, dir);
42  } catch (error) {
43    // workflow-cli missing from PATH, or timed out.
44    return {
45      error: `workflow-cli could not run: ${error instanceof Error ? error.message : String(error)}`,
46    };
47  }
48}
49
50async function refresh($: EngineInterface): Promise<void> {
51  const next = await readSnapshot($);
52  await update($, snapshot, () => next);
53}
54
55export const register: Register = (on) => {
56  on("session.start", async ($, e, next) => {
57    const result = await next(e);
58    await refresh($);
59    return result;
60  });
61
62  on("session.end", async ($, e, next) => {
63    // After /clear the process goes on under a new session id and no
64    // session.start follows; the old session's gate must not linger.
65    if (e.reason === "clear") await update($, snapshot, () => null);
66    return next(e);
67  });
68
69  on("turn.complete", async ($, e, next) => {
70    const result = await next(e);
71    if (!e.agentId) await refresh($);
72    return result;
73  });
74
75  // Mid-turn refreshes: a workflow document written, or workflow-cli run
76  // (round / stamp / triage move the marker).
77  on("tool.call", { tool: ["Write", "Edit"] }, async ($, e, next) => {
78    const result = await next(e);
79    const path = "file_path" in e ? String(e.file_path ?? "") : "";
80    if (WORKFLOW_DOC_PATH.test(path)) await refresh($);
81    return result;
82  });
83  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
84    const result = await next(e);
85    if (String(e.command ?? "").includes("workflow-cli")) await refresh($);
86    return result;
87  });
88
89  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
90    const current = await read($, snapshot);
91    const rows = layoutBand(current, {
92      columns: e.props.bodyColumns - 2,
93      maxRows: e.props.maxRows,
94    });
95    if (e.props.hasSurvey || rows === null) return next(e);
96
97    const { Box, Text } = $.ui.resolve(e);
98    return (
99      <Box flexDirection="column" paddingX={1}>
100        {rows.map((row, rowIndex) => (
101          <Box key={rowIndex} flexDirection="row">
102            {row.map((segment, index) => (
103              <Text
104                key={index}
105                color={segment.color}
106                bold={segment.bold}
107                dimColor={segment.dim}
108              >
109                {segment.text}
110              </Text>
111            ))}
112          </Box>
113        ))}
114      </Box>
115    );
116  });
117};
118
hooks/layout.ts 88 lines
1import type { WorkflowFailure, WorkflowSnapshot } from "../types";
2import {
3  arePlansReady,
4  isComplete,
5  isIdle,
6  planNumber,
7  shortName,
8  truncate,
9} from "./parse";
10
11export type Segment = {
12  text: string;
13  color?: "cyan" | "green" | "red" | "yellow";
14  bold?: boolean;
15  dim?: boolean;
16};
17
18/**
19 * The band as rows of styled text, or null when it has nothing to draw.
20 * Free of the engine on purpose: the session turns these rows into components
21 * and `preview.ts` into ANSI, so the two cannot show different things.
22 */
23export function layoutBand(
24  current: WorkflowSnapshot | WorkflowFailure | null,
25  { columns, maxRows }: { columns: number; maxRows: number },
26): Segment[][] | null {
27  if (current === null) return null;
28  if ("error" in current) {
29    return [[{ text: truncate(`WF ${current.error}`, columns), color: "red" }]];
30  }
31  if (isIdle(current)) return null;
32
33  // With every condition met the checklist collapses to one mark. In
34  // two-layer mode a green spec.md does not mean writes are allowed, so
35  // the plan-N.md side is drawn next to it and `Next:` stays until those
36  // clear too. A warning always gets its line.
37  const complete = isComplete(current);
38  const plansReady = arePlansReady(current);
39  const settled = complete && (!current.twoLayer || plansReady);
40
41  const first: Segment[] = [
42    { text: "WF ", color: "cyan", bold: true },
43    { text: `${current.doc} ` },
44  ];
45  if (current.source === "env") {
46    first.push({ text: "[pinned] ", color: "yellow" });
47  }
48  if (complete) {
49    first.push({ text: "✓ ", color: "green" });
50  } else {
51    for (const check of current.checks) {
52      first.push({
53        text: `${check.ok ? "✓" : "✗"}${shortName(check.name)} `,
54        color: check.ok ? "green" : "red",
55      });
56    }
57  }
58  if (current.twoLayer) {
59    first.push({ text: "· plan ", dim: true });
60    if (current.plans.length === 0) {
61      first.push({ text: "none yet", dim: true });
62    } else if (plansReady) {
63      first.push({ text: `✓${current.plans.length}`, color: "green" });
64    } else {
65      for (const plan of current.plans) {
66        first.push({
67          text: `${planNumber(plan.name)}${plan.blockedBy ? `✗${shortName(plan.blockedBy)}` : "✓"} `,
68          color: plan.blockedBy ? "red" : "green",
69        });
70      }
71    }
72  }
73
74  const rows = [first];
75  if (maxRows >= 2) {
76    if (current.warning) {
77      rows.push([
78        { text: truncate(`⚠ ${current.warning}`, columns), color: "yellow" },
79      ]);
80    } else if (current.next && !settled) {
81      rows.push([
82        { text: truncate(`Next: ${current.next}`, columns), dim: true },
83      ]);
84    }
85  }
86  return rows;
87}
88
hooks/parse.ts 142 lines
1import type {
2  GateCheck,
3  PlanState,
4  WfDirSource,
5  WorkflowFailure,
6  WorkflowSnapshot,
7} from "../types";
8
9// The text formats come from workflow-gate.ts `formatGateChecklist` and
10// cli/workflow.ts `cmdStatus` / `cmdDir`. Lines this parser does not know are
11// skipped, so a new line in the CLI's output degrades to "not shown".
12const HEADER =
13  /^Document workflow gate( \(two-layer\))?: conditions on `([^`]+)`:/;
14const CHECK = /^\s+([✓✗]) (.+?)(?: \((.*)\))?$/;
15const PLAN = /^plan: (plan-[0-9]+\.md) (?:✓|✗ (.+))$/;
16
17export function parseStatus(
18  stdout: string,
19): Omit<WorkflowSnapshot, "source" | "wfDir" | "warning"> | null {
20  let doc: string | undefined;
21  let twoLayer = false;
22  const checks: GateCheck[] = [];
23  const plans: PlanState[] = [];
24  let note: string | undefined;
25  let next: string | undefined;
26  let tripwire: string | undefined;
27
28  for (const line of stdout.split("\n")) {
29    const header = HEADER.exec(line);
30    if (header) {
31      twoLayer = header[1] !== undefined;
32      doc = header[2];
33      continue;
34    }
35    const check = CHECK.exec(line);
36    if (check?.[2]) {
37      checks.push({
38        name: check[2],
39        ok: check[1] === "✓",
40        ...(check[3] ? { detail: check[3] } : {}),
41      });
42      continue;
43    }
44    const plan = PLAN.exec(line);
45    if (plan?.[1]) {
46      plans.push({
47        name: plan[1],
48        ...(plan[2] ? { blockedBy: plan[2] } : {}),
49      });
50      continue;
51    }
52    if (line.startsWith("  note: ")) note = line.slice("  note: ".length);
53    else if (line.startsWith("Next: ")) next = line.slice("Next: ".length);
54    else if (line.startsWith("tripwire: "))
55      tripwire = line.slice("tripwire: ".length);
56  }
57
58  if (doc === undefined || checks.length === 0) return null;
59  return { doc, twoLayer, checks, plans, note, next, tripwire };
60}
61
62export function parseDir(stdout: string): {
63  wfDir?: string;
64  source: WfDirSource;
65} {
66  const wfDir = /^wfDir=(.*)$/m.exec(stdout)?.[1];
67  const raw = /^source=(.*)$/m.exec(stdout)?.[1];
68  const source: WfDirSource =
69    raw === "derived" || raw === "env" || raw === "override" ? raw : "unknown";
70  return { wfDir, source };
71}
72
73/**
74 * One finished `workflow-cli status` and `workflow-cli dir` as what the band
75 * draws. A status that failed or could not be read becomes a failure, so the
76 * cause is shown instead of an empty band.
77 */
78export function toSnapshot(
79  status: { exitCode: number | null; stdout: string; stderr: string },
80  dir: { stdout: string },
81): WorkflowSnapshot | WorkflowFailure {
82  if (status.exitCode !== 0) {
83    return {
84      error: `workflow-cli status exited ${status.exitCode}: ${status.stderr.trim() || status.stdout.trim()}`,
85    };
86  }
87  const parsed = parseStatus(status.stdout);
88  if (!parsed) {
89    return {
90      error: `workflow-cli status output not recognised: ${status.stdout.split("\n")[0] ?? ""}`,
91    };
92  }
93  const warning = status.stderr.trim();
94  return {
95    ...parsed,
96    ...parseDir(dir.stdout),
97    ...(warning ? { warning } : {}),
98  };
99}
100
101/** Nothing has been written into the workflow dir yet: the band has nothing to say. */
102export function isIdle(snapshot: WorkflowSnapshot): boolean {
103  return snapshot.checks.every((check) => !check.ok);
104}
105
106/** Every condition holds: the individual checks no longer tell the reader anything. */
107export function isComplete(snapshot: WorkflowSnapshot): boolean {
108  return snapshot.checks.every((check) => check.ok);
109}
110
111/** Two-layer mode with at least one plan-N.md, none of them held back. */
112export function arePlansReady(snapshot: WorkflowSnapshot): boolean {
113  return (
114    snapshot.plans.length > 0 && snapshot.plans.every((plan) => !plan.blockedBy)
115  );
116}
117
118/** `plan-12.md` → `12`, the label a plan gets in the band. */
119export function planNumber(name: string): string {
120  return /^plan-([0-9]+)\.md$/.exec(name)?.[1] ?? name;
121}
122
123const SHORT_NAMES: Record<string, string> = {
124  "parent-spec-hash": "parent",
125  "research.md": "research",
126  "Plan Status": "plan",
127  "Review Status": "review",
128  "Approval Status": "approval",
129  "marker verdict": "verdict",
130  "hash match": "hash",
131  approval: "ledger",
132};
133
134export function shortName(name: string): string {
135  return SHORT_NAMES[name] ?? name;
136}
137
138export function truncate(text: string, columns: number): string {
139  if (columns <= 1) return "";
140  return text.length <= columns ? text : `${text.slice(0, columns - 1)}…`;
141}
142
types/index.d.ts 43 lines
1export type GateCheck = {
2  /** The condition's name as workflow-cli prints it, e.g. `Review Status`. */
3  name: string;
4  ok: boolean;
5  /** The parenthesised `found: ...; expected: ...` text of a failing check. */
6  detail?: string;
7};
8
9export type PlanState = {
10  /** `plan-N.md`. */
11  name: string;
12  /** The first condition the plan does not meet, as workflow-cli names it; absent when it clears. */
13  blockedBy?: string;
14};
15
16/** Where workflow-cli took the workflow dir from (`workflow-cli dir`'s `source=`). */
17export type WfDirSource = "derived" | "env" | "override" | "unknown";
18
19export type WorkflowSnapshot = {
20  /** `plan.md`, or `spec.md` in two-layer mode. */
21  doc: string;
22  twoLayer: boolean;
23  checks: GateCheck[];
24  /** Two-layer mode: every plan-N.md in number order. Empty in single-layer mode. */
25  plans: PlanState[];
26  note?: string;
27  next?: string;
28  tripwire?: string;
29  wfDir?: string;
30  source: WfDirSource;
31  /** What workflow-cli wrote to stderr, such as a rejected DOCUMENT_WORKFLOW_DIR pin. */
32  warning?: string;
33};
34
35/** workflow-cli could not be read; drawn instead of hidden so the cause is visible. */
36export type WorkflowFailure = { error: string };
37
38declare module "claude-code" {
39  interface PluginState {
40    "workflow-band": { snapshot: WorkflowSnapshot | WorkflowFailure | null };
41  }
42}
43