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

depends on chezmoi
sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/binwinget install twpayne.chezmoiSet-ExecutionPolicy -ExecutionPolicy ByPass -Scope Processchezmoi init --apply berlysia~/.local/bin will be added to $PATH~/.local/.bin will be added to $PATH , and overwrite this directory with symlinkTailscale経由のSSHと、公開リポジトリに端末情報を置かない鍵管理はSSH設定の手順を参照。
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.
Claude workflows use environment-scoped secrets instead of repository-wide secrets.
claude-manual: used by .github/workflows/claude.ymlclaude-autofix: used by .github/workflows/auto-fix-dependencies.ymlAdd CLAUDE_CODE_OAUTH_TOKEN to both environments. If you want unattended automation, leave approvals and wait timers disabled.
This dotfiles project includes tools for analyzing Claude Code hook decision logs:
home/dot_claude/scripts/update-auto-approve.tsAutomated 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:
~/.claude/logs/decisions.jsonl for permission patternsThis repository includes comprehensive Claude Code configuration with:
.skills/ directory)~/.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:
.settings.hooks.json.tmpl and run chezmoi applyenabledPlugins settings are preserved (user manual changes are not overwritten)~/.claude.json (MCP servers configuration) is automatically managed by run_onchange_update-claude-json.sh.tmpl:
How it works:
package.json dependencies~/.claude.jsonmcpServers, preferredNotifChannel, and defaultModeManaged MCP servers:
@mizchi/readability - Web page content extractionchrome-devtools-mcp - Chrome DevTools automation@playwright/mcp - Browser automation@upstash/context7-mcp - Documentation searchTo update versions: Edit package.json dependencies and run chezmoi apply
This project uses a two-layer approach for managing Claude Code skills:
.skills/)${projectRoot}/.skills/ for unified management (at repo root, outside home/)~/.claude/skills/ and ~/.codex/skills/ via run_after_sync-skills.sh.tmplhome/dot_apm/apm.yml)apm.yml)apm install -g from GitHub repositoriesapm.lock.yamlBoth layers coexist in ~/.claude/skills/ directory.
Plugins are managed declaratively through .settings.plugins.json:
After chezmoi apply:
show-missing-plugins.sh detects missing pluginsSyncing plugin changes back to dotfiles:
~/.claude/scripts/sync-enabled-plugins.sh
This exports current enabledPlugins from ~/.claude/settings.json to dotfiles for version control.
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.
See .skills/ directory for available skills. Key skills include setup-claude-skills-for-web, commit-conventions, react-hooks, and logic-validation.
hooks/register.tsx 118 lines1import { 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};
118hooks/layout.ts 88 lines1import 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}
88hooks/parse.ts 142 lines1import 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}
142types/index.d.ts 43 lines1export 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