Pane that shows each task's node and state from a workflow-graph log (.workflow/log.jsonl)

给 coding agent 用的 skill 集合,外加它们依赖的命令行工具。一个仓库,skills/<name>/SKILL.md 每个目录一个 skill,用本仓库的 bin/wayne-skills.js 装给本机的 Claude Code / Codex / Pi / Grok / Antigravity。
| skill | 做什么 | 依赖 |
|---|---|---|
okr | 替用户记目标 / KR / 任务 / 习惯的进展,看板、评估、拆解、周计划、派工 | okr CLI(本仓库) |
terminal-diagrams | 用户在终端里读回复时,agent 不发 ``mermaid 源码,而是用 mmd2txt` 渲染成框线字符图再贴 | mmd2txt,npm install -g github:RoacherM/mmd2txt |
workflow-graph | 把多执行者(人、模型、脚本)参与的工作流设计成能运行的图:循环、节点、交接物、回边、闸门、三个指标;只读这一个文件就能拿到全部流程和术语 | 无 |
git-story-film | 给一个 GitHub 仓库,按 Git 历史里真实的提交、版本、回滚编故事,做成约两分钟的手绘动画短片(大纲→角色→分镜→浏览器 Studio 预览→4K/60fps MP4)。复制自 EverMind-AI/Raven,Apache-2.0 | Node 22+、Google Chrome、ffmpeg、Python 3.8+ |
sw-*、chekhov-dramaturgy、ozu-screenplay-style | 13 个编剧 skill:流程调度、结构、前提、人物、对白、场景、格式改编、美日韩法案例、行业、契诃夫、小津。复制自 jtydhr88/screenwriting-skills v1.3.0,清单见 skills/README.md | 无 |
所有全局 skill(本仓库的和别人仓库的)都用 bin/wayne-skills.js 装。它调 skills CLI,但只给本机装了的 agent 建链接:
| agent | 读哪里 | 安装器做什么 |
|---|---|---|
| Codex、Pi、Grok | ~/.agents/skills(Pi、Grok 另外也读自己的目录) | 什么都不用,skills CLI 总会把正本写在这里 |
| Claude Code | ~/.claude/skills | 有 ~/.claude 才传 -a claude-code,由 skills CLI 建软链 |
| Antigravity | ~/.gemini/config/skills(skills CLI 以为它读 ~/.agents/skills,不对) | 有 ~/.gemini/antigravity 才由安装器自己建软链 |
W=~/Desktop/Projects/sides/wayne-skills/bin/wayne-skills.js
node $W add RoacherM/Wayne-Skills -s okr -s git-story-film # 装别人的就换 source:owner/repo 或 URL
node $W update # 按 ~/.agents/.skill-lock.json 全部重装;也可只写几个 skill 名
node $W remove git-story-film
不要直接跑 npx skills add 不带 -a,也不要跑 npx skills update -g:这两种情况下 skills CLI 会把 skill 装给所有「home 下有目录」的 agent,并给每个 agent 新建 skills 目录,下次更新又把这些目录当成已安装的 agent,目录越积越多(2026-09-29 清掉过 52 个)。update 跳过 ~/.agents/skills 里是软链的 skill(如 ego-browser,由它自己的安装器管)。
装完 agent 直接跑 node ~/.agents/skills/okr/scripts/okr.js …(SKILL.md 里写了)。终端里想直接敲 okr:
node ~/.agents/skills/okr/scripts/okr.js skill link # 软链到 ~/.local/bin/okr;--dir 换目录
另一条路,先要命令再要 skill:npm install -g github:RoacherM/Wayne-Skills 把 okr 放到 PATH 上,第一次运行时把 skill 复制到 ~/.agents/skills/okr 并给 Claude Code 建软链 ~/.claude/skills/okr(只装这两处;之后随包自动更新自己装的那份,OKR_SKIP_SKILL=1 关掉;okr skill install | remove | status 手动)。不用 npm postinstall 是因为 npm 11 会把带安装脚本的 git 全局包装成指向临时 clone 的软链。--no-audit:首次拉包时 npm 的 audit 请求在某些网络下会静默挂几分钟。
13 个编剧 skill 不走插件,也不装到全局,只用 npx 以项目级复制装进本仓库:在本仓库里打开的 Claude Code 读 .claude/skills/,Codex 读 .agents/skills/。两份拷贝和 skills-lock.json 都提交在仓库里,clone 下来就能用。改了 skills/ 下的编剧 skill 后,在仓库根目录重跑:
npx -y --no-audit skills add ./ -y -a claude-code -a codex --copy \
-s sw-workflow -s sw-story-structure -s sw-premise-theme -s sw-character-conflict -s sw-dialogue -s sw-scene-craft \
-s sw-format-adaptation -s sw-american-case-studies -s sw-japanese-screenwriting -s sw-korean-french-screenwriting \
-s sw-industry-business -s chekhov-dramaturgy -s ozu-screenplay-style
global/AGENTS.md 是给所有 coding agent 的全局指令,一份文件,换机器时 clone 仓库后跑一次就同步:
bash global/link.sh # 拷到 ~/.agents/AGENTS.md,再软链到 ~/.claude/CLAUDE.md、~/.codex/AGENTS.md、~/.pi/agent/AGENTS.md;原有文件留作 .bak-日期;corrections/README.md 拷到 ~/.agents/corrections/
bash global/link.sh --remove # 只删指向本文件的软链
写法按 Anthropic 的 Fable 5.1 提示指南和 OpenAI 的 GPT-6 Astra 模型指南:两代模型都会严格照做,所以只写 harness 没说的事(回复语言、改动范围、密钥、终端画图、git 边界),不重复系统提示里已有的段落,不写互相矛盾的规则。Claude Code 自带的 Fable 5.1 提示块已覆盖进度汇报、并行调用、完成整个任务、交付范围、排版密度,这些不再写。
global/claude/ 只放自己改过的 Claude Code 设置,换机器后跑一次就同步:
| 文件 | 内容 |
|---|---|
settings.json | 自动 compact 窗口 400k;关遥测、错误上报、/bug、非必要流量;状态栏;跳过危险模式确认;启动时不开远程控制 |
statusline.sh | 状态栏:模型 · 目录 · git · 上下文占比 · 5h/7d 限额和重置倒计时,依赖 jq |
bash global/claude/apply.sh # 把 settings.json 合并进 ~/.claude/settings.json(原文件留作 .bak-时间),再拷 statusline.sh;重启 Claude Code 生效
bash global/claude/apply.sh --check # 只看哪些键不同,不改文件
合并只覆盖仓库里列出的键,env 里的其他变量保留。模型列表、ANTHROPIC_*、CLAUDE_CODE_MODEL_CAPABILITIES、model、effortLevel 由 magpie 写,herdr 的 SessionStart 钩子由 herdr 写,都不放进仓库。在本机改了这几项后,同步改仓库里的 settings.json 再提交。
目标与任务追踪,终端里看,agent 也能读写。事件进,视图出。用法见 docs/okr/README.md,设计见 docs/okr/DESIGN.md,agent 读写规则见 docs/okr/PROTOCOL.md(装好后 okr protocol 直接打印)。
okr init && okr # 初始化 ~/.okr,打开 TUI
okr status # 看板(管道里是纯文本,--md 出 markdown)
okr week # 本周计划 / 遗留 / 吞吐
okr brief # 今天该注意什么
okr log kr1 "基线跑完" --value 80
okr show kr1.2 --spec # 派工包
git clone https://github.com/RoacherM/Wayne-Skills ~/Desktop/Projects/sides/wayne-skills && cd ~/Desktop/Projects/sides/wayne-skills
npm install && npm link # okr 指向仓库源码(跑源码要 Node ≥ 23.6),改代码即生效;会覆盖 npm install -g 装的那份,回发布版重跑 npm install -g
npm run bundle # 改了 src/ 或 PROTOCOL.md 后重新打包 skills/okr/scripts/okr.js,测试会检查它没过期
npm test && npm run typecheck
for s in ~/Desktop/Projects/sides/wayne-skills/skills/*/; do n=$(basename $s); rm -rf ~/.agents/skills/$n && ln -s $s ~/.agents/skills/$n; done # skill 用软链,改 SKILL.md 即生效
okr skill install # 只补 Claude Code 的软链;~/.agents/skills 里已是软链就不动,自动安装也不碰软链
加新 skill 看 skills/README.md。
MIT
hooks/register.js 149 lines1// Workflow pane: /workflow-pane opens a pane with one row per task in .workflow/log.jsonl
2// (workflow-graph SKILL.md section 6), and a line under the prompt keeps the counts.
3// It only displays; it never writes the log or starts a turn.
4import { NEEDS_MAIN, STATE_LABELS, deriveTasks, formatDuration, parseLog, sortRows, summary } from './derive.js'
5
6const PANE = 'workflow-pane'
7const LOG = '.workflow/log.jsonl'
8const REFRESH_MS = 5000
9const FINISHED_SHOWN = 5
10const COLUMN_WIDTHS = [10, 10, 10, 10, 8]
11
12const STATE_STYLE = {
13 done: { color: 'yellow', bold: true },
14 question: { color: 'yellow', bold: true },
15 handoff: { color: 'yellow', bold: true },
16 blocked: { color: 'red', bold: true },
17 'gate-fail': { color: 'red', bold: true },
18 finished: { dimColor: true },
19}
20
21let rows = []
22let badLines = [] // { line, message } for log lines that could not be read
23let error = '' // a failure that stops the whole refresh; the last good rows stay on screen
24let missing = false
25let refreshing = false
26let lastSeen = null // task → state and time of its deciding fact; null until the first refresh
27let cache = { mtimeMs: -1, size: -1, text: '' }
28
29async function readLog($) {
30 if (!(await $.fs.exists(LOG))) return null
31 const stat = await $.fs.stat(LOG)
32 if (stat.mtimeMs !== cache.mtimeMs || stat.size !== cache.size) {
33 cache = { mtimeMs: stat.mtimeMs, size: stat.size, text: await $.fs.read(LOG) }
34 }
35 return cache.text
36}
37
38// A toast for each task that newly waits on the orchestrator. Comparing the latest fact's time
39// too means a second question in the same node still toasts.
40function announceChanges($) {
41 const seen = new Map(rows.map((r) => [r.task, r.state + ':' + r.lastMs]))
42 if (lastSeen) {
43 for (const r of rows) {
44 if (NEEDS_MAIN.has(r.state) && lastSeen.get(r.task) !== seen.get(r.task)) {
45 $.ui.toast(r.task + ' · ' + r.node + ' → ' + STATE_LABELS[r.state])
46 }
47 }
48 }
49 lastSeen = seen
50}
51
52function statusLine() {
53 if (error) return error
54 if (missing) return 'no ' + LOG + ' here'
55 const parts = [summary(rows) || '无进行中的任务']
56 if (badLines.length) parts.push(badLines.length + ' 行无效')
57 return parts.join(' · ')
58}
59
60async function refresh($) {
61 if (refreshing) return
62 refreshing = true
63 try {
64 const text = await readLog($)
65 missing = text === null
66 const { facts, errors } = parseLog(text ?? '')
67 badLines = errors
68 rows = deriveTasks(facts, await $.clock.now())
69 error = ''
70 announceChanges($)
71 } catch (err) {
72 error = String(err.message ?? err)
73 } finally {
74 refreshing = false
75 $.ui.status(statusLine())
76 $.ui.invalidate('ui.render')
77 }
78}
79
80// One line of the table: cells = [[text, textProps], ...] in COLUMN_WIDTHS order.
81function drawLine({ Box, Text }, key, cells) {
82 return Box({
83 key,
84 flexDirection: 'row',
85 columnGap: 2,
86 children: cells.map(([text, props], i) =>
87 Box({ width: COLUMN_WIDTHS[i], children: [Text({ wrap: 'truncate-end', ...props, children: [text] })] }),
88 ),
89 })
90}
91
92function drawRow(el, r) {
93 const finished = r.state === 'finished'
94 const rounds = r.rounds > 0 ? '退回 ' + r.rounds + (r.cap ? '/' + r.cap : '') : ''
95 return drawLine(el, r.task, [
96 [r.task, finished ? { dimColor: true } : { bold: true }],
97 [r.node, { dimColor: finished }],
98 [STATE_LABELS[r.state], STATE_STYLE[r.state] ?? {}],
99 [rounds, r.cap && r.rounds >= r.cap ? { color: 'red' } : { dimColor: true }],
100 [formatDuration(r.cycleMs), { dimColor: true }],
101 ])
102}
103
104export function register(on) {
105 on('session.start', async ($, e, next) => {
106 $.clock.every(REFRESH_MS, () => refresh($))
107 await refresh($)
108 await $.command.register({
109 name: 'workflow-pane',
110 description: 'Open the workflow pane (.workflow/log.jsonl)',
111 immediate: true,
112 })
113 return next(e)
114 })
115
116 on('command.run', { command: 'workflow-pane' }, async ($) => {
117 await refresh($)
118 await $.ui.open({ id: PANE, title: 'Workflow', focus: true, closeOnEscape: true })
119 return {}
120 })
121
122 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
123 if (e.requestId !== PANE) return next(e)
124 const el = $.ui.resolve(e)
125 const { Box, Text } = el
126 const { active, finished } = sortRows(rows)
127 const shown = finished.slice(0, FINISHED_SHOWN)
128 const children = []
129 if (error) children.push(Text({ color: 'red', children: [error] }))
130 if (missing) children.push(Text({ dimColor: true, children: ['这里还没有 ' + LOG] }))
131 if (badLines.length) {
132 const first = badLines[0]
133 children.push(
134 Text({ color: 'red', wrap: 'truncate-end', children: [badLines.length + ' 行无效,第一处:第 ' + first.line + ' 行 ' + first.message] }),
135 )
136 }
137 children.push(
138 Text({ bold: true, children: [summary(rows) || '无进行中的任务'] }),
139 drawLine(el, 'header', ['任务', '节点', '状态', '退回', '周期'].map((t) => [t, { dimColor: true }])),
140 ...active.map((r) => drawRow(el, r)),
141 ...shown.map((r) => drawRow(el, r)),
142 )
143 if (finished.length > shown.length) {
144 children.push(Text({ dimColor: true, children: ['更早完成 ' + (finished.length - shown.length) + ' 个'] }))
145 }
146 return Box({ flexDirection: 'column', children })
147 })
148}
149hooks/derive.js 125 lines1// Reads a workflow-graph log (.workflow/log.jsonl, SKILL.md section 6) and derives one row per
2// task. Nothing is stored: every row is computed again from the facts.
3
4export const EVENTS = new Set(['start', 'done', 'question', 'answer', 'blocked', 'gate', 'return', 'handoff', 'finish'])
5const REQUIRED = ['time', 'task', 'event', 'node', 'by']
6
7export const STATE_LABELS = {
8 working: '进行中',
9 done: '已交付',
10 question: '有提问',
11 blocked: '卡住',
12 'gate-pass': '闸门通过',
13 'gate-fail': '闸门未过',
14 fixing: '修改中',
15 handoff: '已移交',
16 finished: '完成',
17}
18
19// SKILL.md section 6, step 5: states that wait on the orchestrator.
20export const NEEDS_MAIN = new Set(['done', 'question', 'blocked', 'gate-fail', 'handoff'])
21
22// Returns the valid facts, each with timeMs and its line number, and one error per bad line.
23export function parseLog(text) {
24 const facts = []
25 const errors = []
26 text.split('\n').forEach((raw, i) => {
27 const line = raw.trim()
28 if (!line) return
29 const fail = (message) => errors.push({ line: i + 1, message })
30 let fact
31 try {
32 fact = JSON.parse(line)
33 } catch {
34 return fail('not JSON')
35 }
36 const missing = REQUIRED.filter((k) => typeof fact?.[k] !== 'string' || fact[k] === '')
37 if (missing.length) return fail('missing ' + missing.join(', '))
38 if (!EVENTS.has(fact.event)) return fail('unknown event ' + fact.event)
39 if (fact.event === 'gate' && fact.result !== 'pass' && fact.result !== 'fail') {
40 return fail('gate result must be pass or fail')
41 }
42 const timeMs = Date.parse(fact.time)
43 if (Number.isNaN(timeMs)) return fail('bad time ' + fact.time)
44 facts.push({ ...fact, timeMs, lineNo: i + 1 })
45 })
46 return { facts, errors }
47}
48
49function stateOf(fact) {
50 switch (fact.event) {
51 case 'start':
52 return { state: 'working', node: fact.node }
53 case 'done':
54 return { state: 'done', node: fact.node }
55 case 'question':
56 return { state: 'question', node: fact.node, to: fact.to }
57 case 'blocked':
58 return { state: 'blocked', node: fact.node }
59 case 'gate':
60 return { state: fact.result === 'pass' ? 'gate-pass' : 'gate-fail', node: fact.node, gate: fact.gate }
61 case 'return':
62 return { state: 'fixing', node: fact.to ?? fact.node }
63 case 'handoff':
64 return { state: 'handoff', node: fact.node, to: fact.to }
65 case 'finish':
66 return { state: 'finished', node: fact.node }
67 }
68}
69
70// SKILL.md section 6, step 2: an unanswered question decides; otherwise the latest fact that is
71// neither a question nor an answer does.
72function decidingFact(facts) {
73 const asked = facts.filter((f) => f.event === 'question' || f.event === 'answer')
74 const lastAsked = asked[asked.length - 1]
75 if (lastAsked?.event === 'question') return lastAsked
76 return facts.findLast((f) => f.event !== 'question' && f.event !== 'answer') ?? null
77}
78
79export function deriveTasks(facts, nowMs) {
80 const byTask = new Map()
81 for (const f of facts) {
82 if (!byTask.has(f.task)) byTask.set(f.task, [])
83 byTask.get(f.task).push(f)
84 }
85 const rows = []
86 for (const [task, list] of byTask) {
87 list.sort((a, b) => a.timeMs - b.timeMs || a.lineNo - b.lineNo)
88 const last = list[list.length - 1]
89 const deciding = decidingFact(list)
90 const state = deciding ? stateOf(deciding) : { state: 'working', node: last.node }
91 const returns = list.filter((f) => f.event === 'return')
92 const rounds = Math.max(0, ...returns.map((f) => Number(f.round) || 0))
93 const cap = returns.findLast((f) => f.cap !== undefined)?.cap
94 const startedMs = list[0].timeMs
95 const endMs = state.state === 'finished' ? deciding.timeMs : nowMs
96 rows.push({ task, ...state, rounds, cap, startedMs, cycleMs: endMs - startedMs, lastMs: last.timeMs })
97 }
98 return rows
99}
100
101export function formatDuration(ms) {
102 const minutes = Math.max(0, Math.round(ms / 60000))
103 if (minutes < 60) return minutes + 'm'
104 const hours = Math.floor(minutes / 60)
105 if (hours < 48) return hours + 'h' + String(minutes % 60).padStart(2, '0') + 'm'
106 return Math.floor(hours / 24) + 'd'
107}
108
109// Active tasks first, oldest start first; finished tasks after, most recently finished first.
110export function sortRows(rows) {
111 const active = rows.filter((r) => r.state !== 'finished').sort((a, b) => a.startedMs - b.startedMs)
112 const finished = rows
113 .filter((r) => r.state === 'finished')
114 .sort((a, b) => b.startedMs + b.cycleMs - (a.startedMs + a.cycleMs))
115 return { active, finished }
116}
117
118export function summary(rows) {
119 const counts = {}
120 for (const r of rows) if (r.state !== 'finished') counts[r.state] = (counts[r.state] ?? 0) + 1
121 return Object.entries(counts)
122 .map(([state, n]) => n + ' ' + STATE_LABELS[state])
123 .join(' · ')
124}
125