SLOPSHOPPER

decision-board

A pane with the project's open questions (DECISIONS.json), most urgent first, with buttons to answer one directly.

newpaneguardcommandtoolprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-board
│ ┃ Questions ✕ › fix the failing auth test and add an audit log call │ ┃ Open questions [ Close ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ View: [ Open ] [ Pending ] [ Implemented ] ⎿ Read 6 lines │ ┃ Priority: [ urgent ] [ high ] [ normal ] [ l ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No open questions. ⏺ 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 │ │ › /decision-board │ ⎿ decision-board: Decision board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Questions
Open questions [ Close ] View: [ Open ] [ Pending ] [ Implemented ] Priority: [ urgent ] [ high ] [ normal ] [ low ] [ someday ] No open questions.
README

AI Skills

A small collection of reusable SKILL.md instructions for coding agents. The skills are plain Markdown, version-controlled, and portable across compatible agent tools.

This README is the reference: what the skills are, which platforms they support, and how to install them. The [wiki][wiki] answers the other question — why the project is built this way: why a Markdown repository contains Rust crates, why the dev shell compiles bash 3.2 from source, which design choices were made and what they cost, how to verify any of it yourself, and what does not work yet.

[wiki]: https://github.com/tschallacka/ai-skills/wiki

Install

Pick whichever fits how you work. Any of these opens the same interactive installer, where you choose the skills and the agent destination(s).

npx — installs and runs in one step, nothing left on PATH afterward:

npx --yes --package @tschallacka/ai-skills ai-skills-install

Linux / macOS — the one-command installer, no npm required:

curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh

Windows — the identical command, run inside Git Bash or WSL2 (both provide the POSIX sh it needs; there is no separate PowerShell/cmd installer):

curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh

See "One-command installer" below for the full list of install destinations, and "npm installation" for a persistent ai-skills-install command on PATH instead of npx.

Skills

SkillPurposeDocumentation
PlanningDurable, resumable plans: goals, ordered steps, verification, progress trackers, handoff notes — plus a single-file HTML overview of any plan, and a live served version that updates in place. Steps complete with git-diff atomicity evidence, not decorative checkboxes.docs
Bug reportA defect register in one JSON file: every entry carries its reproduction, observed vs expected, the mechanism, and the verification that fails without the fix. bugs add/bugs update write through shared validation; a closure without proof is refused.docs
TodoA work queue that outlives the conversation, in one JSON file: nested tasks, every closed item carries its evidence. todo add/todo update keep the register sound; read recipes print user-ready output.docs
BrainstormShapes an under-specified idea into a recorded, agreed picture (brainstorm.md) before planning, with an adversarial completion pass and a plan-vs-implement gate.docs
Post-implementation reviewAfter-the-fact review of built code with concrete proposed fixes, in three passes: implementer self-analysis, an independent solutions agent, and a critical-feedback agent that ranks every fix.docs
Project-specific deviationsRecords confirmed project behavior and environment quirks in per-project notes that future agents load instead of re-debugging.docs
Resource-limited testingRuns heavyweight commands (suites, builds, analyzers, browsers) under platform-appropriate CPU/memory caps, with honest degradation when a platform has no cap mechanism.docs
ChatRFC-1459 IRC-over-TLS message bus for agents: a rust server a standard TLS IRC client can join, a rust client with UDP discovery and TOFU cert pinning, channels, and additive history/delta reads.docs
Interactive shellOperates a full-screen terminal program an agent has never seen - nano, mc, lynx, a pager, a menu: a rust PTY wrapper publishing each screen change as one JSONL event, compact row views and deltas to keep context small, element discovery, and a unix-socket client for keys, combos, pastes, mouse and resize. POSIX only.docs
Git worktreesParallel agents in one repository: isolated worktree verification, per-agent trees, and merging back in a conflict-aware order without trampling the main checkout.docs
Git merge resolvingConflicts resolved by what each side changed rather than by ours/theirs: reading intent from history, unions that look like choices, regenerated output, and attributing post-merge failures to the side that caused them.docs
Merge request etiquetteDescriptions a reviewer can act on, in the author's voice: a one-paragraph TLDR, the defect/cause/change body, derived from the branch's commits, and the one case where a collapsible section earns its place.docs
Text etiquetteShorthand and a clipped register for an agent's prose - chat, dev talk, and its own thinking: facts first, a shared shorthand with an ask-don't-guess rule, praise capped at gj, and the people-please prose banned. Plain english on request.docs
Question etiquetteNumbered questions, lettered options, never a bullet: a reply like Q7b is unambiguous, a partial answer names exactly which numbers are still open, and a lettered list always ends with "none of these, I'll say it myself."docs
AI text editorServer-owned agent editor tabs with explicit search, revision-aware edits, undo/redo, raw-byte and hex modes, SQLite metadata, and Unix/TCP transport.docs
wwwA brake the human can pull, and one the agent pulls on itself when it is thrashing: stop, answer what do we have / what are the values / what are we trying to achieve, in order, then continue with one reasoned step or a numbered question.docs
CI failuresWhat actually failed in a CI run or pipeline, from a run/pipeline id, a PR/MR number, or a branch: GitHub and GitLab detected from the git remote, named rather than chosen silently, with just the failing lines extracted per job.docs
DecisionsA register of non-blocking questions raised mid-work: Q# ids, lettered options, priority and the branch they came from as context, so a question can be stubbed and left open without blocking a turn.docs
rjqParses, filters and searches JSON with the shipped rjq binary, a jq-compatible tool, on machines without jq.docs
TailpipeA less/tail for agents: pipe a command's output into a named, server-held stream with chat-style message ids; a reader lists/reads/searches/tails it from anywhere, an MCP adapter offers the same as typed tools, and a board mod shows it to a human. Idle streams are gzip-snapshotted after 15 minutes.docs

Use a skill only when its frontmatter trigger matches the task or when the user explicitly requests it. Each skill documents when not to activate.

Supported platforms

"Portable" elsewhere in this repository means portable across agent tools (Claude Code, Codex, OpenCode, OpenClaw, Cline). Operating-system support is separate and stated here:

Those tools do not identify the calling agent the same way, which matters to every per-agent feature here. src/agent-session-key/HARNESS-IDENTITY.md records what each one provides, how it was measured, and the procedure for contributing a harness that is not yet listed.

Supported
Linuxany distribution, bash 4 or 5, GNU userland
macOS11+ with the stock /bin/bash 3.2, BSD userland; Homebrew bash not required
WindowsGit for Windows' bash with its bundled coreutils, checked by CI legs, or WSL2, which is a Linux install. The evidence and the conventions are in .agents/MAINTAINER.md 1.16, which is in a full git checkout and not part of the installed package

The one-command install needs a POSIX sh (bootstrap.sh has no bash-only constructs), plus curl, tar, awk, and standard coreutils; the installed skills' own helper scripts need bash, POSIX coreutils, awk, sed, grep, and git. The planning skill additionally needs rjq, and on macOS resource-limited-testing needs memlimit; the installer checks for both up front and prints a per-platform install hint rather than failing partway through. Those two are the only extra runtime dependencies any skill has — in particular python3 is not required by anything that gets installed, only by this repository's own benchmark harness. CODE-STYLE.md is the contract these scripts are held to; what CI proves on each platform is mapped in .agents/MAINTAINER.md section 3 (a full git checkout has both; the installed package has neither).

One skill is genuinely OS-scoped: resource-limited-testing enforces a hard RAM cap only on Linux, via a transient systemd --user cgroup v2 scope. On macOS it uses memlimit (MIT, by Jelle Besseling), which refuses allocations past the cap instead of killing the process — a best-effort cap on real resident memory over the process tree, not a cgroup-equivalent guarantee; SKILL.md lists what it does and does not promise. On Apple Silicon macOS the installer declares memlimit a soft requirement: without it the skill still installs and the run warns that the RAM cap is not enforced. The wrapper then degrades to nice plus cpulimit (CPU throttling only); on a Linux session without a user systemd instance it falls back to ulimit -v. memlimit is not asked for on Intel Macs at all — it does not support them. Every other skill behaves identically on both.

One-command installer

Run this command and choose the skills and agent destination interactively:

curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh

The installer can install all the skills or one skill, and supports these global skill roots:

DestinationAgent or standard
~/.agents/skillsUniversal Agent Skills root; recommended shared destination
~/.codex/skillsCodex CLI
~/.claude/skillsClaude Code
~/.config/opencode/skillsOpenCode
~/.openclaw/skillsOpenClaw managed skills
~/.cline/skillsCline

The universal root is also discovered by OpenCode and OpenClaw. Installing the same skill into multiple roots can create duplicate definitions or precedence conflicts, so choose only the roots you need.

The interactive installer checks which supported agents are present and omits roots for agents it cannot detect. Custom roots are saved in ~/.config/tsch-ai-skills/custom-locations and are offered again when they still exist.

npm installation

Install the package globally to expose the installer command. The npm package keeps the skills in this repository and links ai-skills-install directly to installer/bootstrap.sh, which fetches the matching compiled installer release for your platform on first run:

npm install -g @tschallacka/ai-skills
ai-skills-install

For a one-off run without a global install:

npx --yes --package @tschallacka/ai-skills ai-skills-install

The npm package does not install skills automatically as an npm lifecycle side-effect; run the installer command when you are ready to choose a target.

Updating an existing install

Run the installer again against the same root. It compares every managed file with the repository copy and reports one of three outcomes per destination: Up to date (nothing differed), Installed (files were written), or Skipped (you declined, or the destination needs manual review).

Each installed skill contains a .version marker identifying its tag, branch, and commit. If an installed file differs from the repository version, the installer asks before replacing it. For a managed version transition that the user approves — the .version marker differs, so the change came from a new release rather than from you — old files are replaced without backups; the previous version can be restored by running the installer against its tag with AI_SKILLS_REF. Unmanaged changes still receive <file>.bak backups (.bak.1, .bak.2, … if one already exists). A symlinked skill is skipped for manual review rather than following the link and modifying an unexpected location.

Installing or updating one skill

Interactively, choose that one skill at the menu. Headless, name it:

curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
  | sh -s -- install --skill planning --target "$HOME/.codex/skills"

--skill may be given more than once, and each value may itself be a comma-separated list, so --skill planning --skill brainstorm and --skill planning,brainstorm install the same two. Repeats are collapsed, menu numbers work (--skill 1,4), and all selects everything wherever it appears.

Headless and CI usage

Use --all, --skill, --target and --yes when the choices are already known. --target takes a single root, so installing into two roots is two runs.

# Install all skills into the shared Agent Skills root
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
  | sh -s -- install --all --target "$HOME/.agents/skills"

# Unattended replacement: managed version transitions replace without backups,
# unmanaged changed files are still backed up as <file>.bak
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
  | sh -s -- install --all --target "$HOME/.agents/skills" --yes

Every run ends with a summary block on stdout saying what was installed, what was not, and why; the progress and diagnostics go to stderr, so installer install … > summary.txt keeps the outcome and 2>/dev/null keeps it readable. A blocked skill is reported once — not once per root — with the commands that finish the job. This is what an --all run on a machine without rjq prints:

== Summary ==
Installed: /home/u/.agents/skills/project-specifics
Installed: /home/u/.agents/skills/resource-limited-testing
Installed: /home/u/.agents/skills/brainstorm
Installed: /home/u/.agents/skills/post-implementation-review
Skipped:   planning — a hard requirement is missing, nothing was written
To install planning once its requirements are met:
  1. install rjq:
    sudo apt-get install -y rjq
  2. replay this run:
  installer install --skill planning --target /home/u/.agents/skills --yes

The install step is chosen for the detected platform and package manager, and the replay line carries the same target and flags as the run that printed it, naming the installer binary bootstrap.sh downloaded to run it. The exit status is non-zero, because four of five skills is a partial install and CI must not read it as success.

Runtime dependencies

Dependencies are declared per skill, so one unsatisfiable dependency never stops the other skills from installing. Each skill ships a requires.tsv naming what it needs, on which platform and architecture, and how badly:

StrengthEffect
hardThe skill does not work without the tool. It is not installed, the run explains why and prints the replay commands, and the exit status is non-zero.
softThe skill works in a degraded form. It is installed, with a warning naming the tool and the capability that is lost. The exit status is unaffected.

Currently:

  • planning requires rjq (hard, every platform) — without it validate-plan.sh refuses to run and the plan gates stop firing.
  • resource-limited-testing names memlimit (soft, Apple Silicon macOS only) — see Supported platforms above for what the degraded path still does.

No other skill has a runtime dependency.

Exit codes

CodeMeaning
0Everything requested was installed. Soft warnings do not change this.
1A requested skill was blocked by a hard requirement, or any other error.
2install-skill only: approval declined, nothing was written.
3install-skill only: an unsafe collision (an existing file that is not a managed version upgrade, or a symlink).

Codes 2 and 3 belong to the machine-facing install-skill subcommand that the planning skill's own tooling uses; the interactive, install --all, and install --skill paths only ever return 0 or 1.

Full-screen installer UI

Running the bare one-liner with no arguments, or installer interactive directly, opens a full-screen skill picker instead of the numbered menu:

KeyAction
↑/k, ↓/j, PageUp, PageDown, Home, Endmove the cursor
Enter / Spacetoggle the skill under the cursor
Tab / Shift-Tabswitch focus between the skill list and the info pane
a / nselect all / select none
d, r, m (info pane focused)show dependency hints, re-verify requirements, cycle a skill's integration mode
iconfirm and install the current selection
q / Escapequit without installing

With neither --target nor --agent given, it also prompts to choose an auto-detected agent root, a saved custom directory, a new custom directory, or a for every listed root.

Review the installer before running it if you do not trust the source. Skills are instructions that may guide agents to run commands or access files.

install.sh retired in favor of a compiled Rust installer (src/installer/); installer/bootstrap.sh is the small, pure-POSIX-sh entry point (#!/usr/bin/env sh, no bash-only constructs) that detects the platform, downloads the matching release, and hands off to it — see CONTRIBUTING.md before editing either.

Supported agent documentation

Development checkout

installer/bootstrap.sh always downloads a release archive, so it is not how a checkout installs its own local files. Build the installer and point it at the checkout with --source instead:

cargo build --release -p installer
./target/release/installer interactive --source .

--package dev (also read from $PACKAGE_SELECTION) ships the MODE: DEV files too — tests, maintainer docs — instead of filtering them out, for installing a working development copy rather than the prod set.

installer/bootstrap.sh itself accepts AI_SKILLS_REPO_URL (a different owner/repo to resolve GitHub's "latest release" redirect against) and AI_SKILLS_RELEASE_URL (an exact archive URL, bypassing that redirect entirely — how RELEASE.md verifies one specific tag).

Notes

  • The planning-skill benchmark harness is agent-agnostic. benchmark/planning/runtime/ makes the worker/reviewer/analyzer launch, session-id extraction, and token telemetry pluggable per CLI: the active agent defaults to codex and is selected with BENCHMARK_AGENT (opencode, claude), with a shared launcher (lib-agent.sh) owning all setsid/timeout/process-group control. See benchmark/planning/runtime/README.md for the contract and first-time setup.
  • Skills are instructions, not standalone applications. They add no dependencies unless a skill explicitly documents one.
  • resource-limited-testing's platform behaviour is described under Supported platforms; its SKILL.md documents each fallback in detail.
  • Read and review third-party skills before enabling them in an agent with access to sensitive files, credentials, or external systems.

License

Distributed under the MIT License.

Source 2 files
hooks/register.tsx 543 lines
1import type { Register } from 'claude-code'
2import { update } from 'claude-code'
3
4// The project's questions (DECISIONS.json), most urgent first. A question's
5// lifecycle is open -> decided -> implemented: the user answering one does
6// not make it vanish from this pane, since a decided question is still
7// outstanding work -- the agent's, not the user's -- until it is actually
8// carried out and marked implemented. The pane's View toggle has one button
9// per status -- Open (needs the user's pick), Pending (decided, awaiting
10// the agent's implementation), Implemented (already done) -- and defaults
11// to Open. The register is read on every draw, the same way
12// register-board reads BUGS.json/TODO.json, so the pane always shows what is
13// on disk; a timer also forces a redraw, the same pattern ci-board's
14// JOB_REFRESH_MS uses, so a question answered or implemented elsewhere (the
15// CLI, or an agent via MCP) updates without the person reopening the pane.
16// The interval is short (REFRESH_MS below): unlike ci-board's network-bound
17// GitHub calls, this is a single cheap local file read, so there is no
18// reason to make a person wait up to 30 seconds to see a question raised a
19// moment ago.
20// Toggled by `enabled` in settings.json pluginConfigs["decision-board"].options.
21
22const PANE = 'decision-board'
23const TOOL = 'show_decision_board'
24const READ = 'read_decision_board'
25const ANSWER = 'answer_decision_board'
26const IMPLEMENT = 'implement_decision_board'
27
28const priorityFilterKey = { plugin: 'decision-board', key: 'priorityFilter' } as const
29const branchOnlyKey = { plugin: 'decision-board', key: 'branchOnly' } as const
30const viewKey = { plugin: 'decision-board', key: 'view' } as const
31const lastLoggedKey = { plugin: 'decision-board', key: 'lastLogged' } as const
32
33// How long the "decision logged" banner stays up after a person answers a
34// question from the pane, in milliseconds -- long enough to read and act on,
35// short enough that it is gone well before anyone forgets why it was there.
36// It piggybacks on the pane's own REFRESH_MS redraw rather than a timer of
37// its own, so it fades on the next tick past this window.
38const BANNER_MS = 8_000
39
40// The part of `$.fs` the reading needs.
41type Fs = {
42  exists: (path: string) => Promise<boolean>
43  read: (path: string) => Promise<string>
44}
45
46// The part of `$.process.run` the resolution needs.
47type ProcessRun = (argv: readonly string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>
48
49type Choice = { letter: string; label: string }
50type Question = {
51  id: string
52  title: string
53  status: string
54  priority: string
55  branch: string
56  options: Choice[]
57  context: string
58  chosen: string | null
59  resolution: string | null
60  created_at: string
61  updated_at: string
62}
63
64type View = 'open' | 'pending' | 'implemented'
65
66const PRIORITY_ORDER = ['urgent', 'high', 'normal', 'low', 'someday']
67
68// One color per priority, so the list reads at a glance without counting on
69// bold alone; a priority this register does not know about (future-proofing
70// against a schema addition) falls back to no color rather than guessing.
71const PRIORITY_COLOR: Record<string, string> = {
72  urgent: 'red',
73  high: 'yellow',
74  normal: 'cyan',
75  low: 'blue',
76  someday: 'gray',
77}
78
79function rank(value: string): number {
80  const index = PRIORITY_ORDER.indexOf(value)
81  return index === -1 ? PRIORITY_ORDER.length : index
82}
83
84// Urgent first; `Array.prototype.sort` is a stable sort, so two questions of
85// the same priority keep the order the register itself lists them in.
86function sortQuestionsUrgentFirst(questions: Question[]): Question[] {
87  return [...questions].sort((a, b) => rank(a.priority) - rank(b.priority))
88}
89
90// Open and decided are both still outstanding (an answer is not the same as
91// an implementation); implemented, closed, dropped and obsolete are resting
92// states this pane does not otherwise surface. `pendingQuestions` is the
93// agent-facing tools' own grouping (what still needs someone's attention,
94// open or decided alike); the pane's View toggle instead shows exactly one
95// status at a time, via `questionsForView`.
96function pendingQuestions(questions: Question[]): Question[] {
97  return questions.filter(q => q.status === 'open' || q.status === 'decided')
98}
99
100function openQuestions(questions: Question[]): Question[] {
101  return questions.filter(q => q.status === 'open')
102}
103
104function decidedQuestions(questions: Question[]): Question[] {
105  return questions.filter(q => q.status === 'decided')
106}
107
108function implementedQuestions(questions: Question[]): Question[] {
109  return questions.filter(q => q.status === 'implemented')
110}
111
112function questionsForView(questions: Question[], view: View): Question[] {
113  if (view === 'implemented') return implementedQuestions(questions)
114  if (view === 'pending') return decidedQuestions(questions)
115  return openQuestions(questions)
116}
117
118// The pane's own priority/branch filters, each optional and independent of
119// the other. Narrowing is the board's own convenience: the agent-facing
120// tools (show/read) never apply it, so an agent always sees the whole truth
121// regardless of what a person last picked in the pane.
122function filterQuestions(
123  questions: Question[],
124  filter: { priority: string | null; branch: string | null },
125): Question[] {
126  return questions.filter(
127    q =>
128      (filter.priority === null || q.priority === filter.priority) &&
129      (filter.branch === null || q.branch === filter.branch),
130  )
131}
132
133// Every question in the register, read from its own already-resolved
134// DECISIONS.json (see resolveDecisionsFile), sorted urgent-first. No
135// register file at all reads as no questions, the same as register-board
136// treats a missing BUGS.json/TODO.json.
137async function loadQuestions(fs: Fs, file: string): Promise<Question[]> {
138  if (!(await fs.exists(file))) return []
139  const parsed = JSON.parse(await fs.read(file)) as { questions?: Question[] }
140  return sortQuestionsUrgentFirst(parsed.questions ?? [])
141}
142
143// The decisions binary's path, in the shared bin the installer puts it in --
144// the same resolution ci-board's own toolPath uses for ci-failures.
145function decisionsBin(home: string, xdg: string): string {
146  const bin = xdg ? `${xdg}/tsch-ai-skills/bin` : `${home}/.config/tsch-ai-skills/bin`
147  return `${bin}/decisions`
148}
149
150// The DECISIONS.json path `decisions` itself actually reads and writes,
151// resolved once via `decisions resolve-path` per handler invocation (its
152// result is threaded through to every reader/writer that same invocation
153// needs, rather than re-resolved) -- an absolute worktree path is used as
154// is; the bare "DECISIONS.json" default is joined onto the session root,
155// exactly where this mod read and wrote it before this resolution existed.
156// A process-run failure (the binary missing, for example) falls back the
157// same way.
158async function resolveDecisionsFile(run: ProcessRun, home: string, xdg: string, root: string): Promise<string> {
159  const result = await run([decisionsBin(home, xdg), 'resolve-path']).catch(() => undefined)
160  if (!result || result.exitCode !== 0) return `${root}/DECISIONS.json`
161  const file = result.stdout.trim()
162  if (file === '') return `${root}/DECISIONS.json`
163  return file.startsWith('/') ? file : `${root}/${file}`
164}
165
166// The argument list `decisions answer <id> <letter> --file PATH` expects,
167// built once here so the button handler and the agent-facing tool call the
168// binary the identical way.
169function answerArgs(bin: string, id: string, letter: string, file: string): string[] {
170  return [bin, 'answer', id, letter, '--file', file]
171}
172
173// `decisions implement <id> [note] --file PATH`; note is optional, unlike
174// answer's letter, so an empty one is simply omitted rather than passed as
175// an empty positional.
176function implementArgs(bin: string, id: string, note: string, file: string): string[] {
177  const args = [bin, 'implement', id]
178  if (note) args.push(note)
179  args.push('--file', file)
180  return args
181}
182
183// "Date when asked", in the loose relative phrasing a person reads faster
184// than a timestamp: same-day counts in minutes/hours, this month in days,
185// the next in "last month", older in whole months, then whole years. An
186// unparseable timestamp reads as '' rather than 'NaN years ago'.
187function relativeAge(iso: string, nowMs: number): string {
188  const then = Date.parse(iso)
189  if (!Number.isFinite(then)) return ''
190  const seconds = Math.max(0, Math.round((nowMs - then) / 1000))
191  const minute = 60
192  const hour = 3600
193  const day = 86400
194  const month = 2_592_000 // 30 days
195  const year = 31_536_000 // 365 days
196  if (seconds < minute) return 'just now'
197  if (seconds < hour) {
198    const n = Math.floor(seconds / minute)
199    return `${n} minute${n === 1 ? '' : 's'} ago`
200  }
201  if (seconds < day) {
202    const n = Math.floor(seconds / hour)
203    return `${n} hour${n === 1 ? '' : 's'} ago`
204  }
205  if (seconds < month) {
206    const n = Math.floor(seconds / day)
207    return n === 1 ? 'yesterday' : `${n} days ago`
208  }
209  if (seconds < month * 2) return 'last month'
210  if (seconds < year) return `${Math.floor(seconds / month)} months ago`
211  const n = Math.floor(seconds / year)
212  return n === 1 ? 'last year' : `${n} years ago`
213}
214
215// The label text for whichever option letter was chosen, or the letter
216// itself if the register no longer lists it (an option set edited after the
217// pick, in principle -- defensive rather than expected).
218function chosenLabel(question: Question): string {
219  const picked = question.options.find(o => o.letter === question.chosen)
220  return picked ? `${question.chosen}: ${picked.label}` : question.chosen ?? ''
221}
222
223// Exposes the pure functions above for a unit test to call directly, without
224// spawning the binary or driving the mod runtime -- nothing here is read by
225// Claude Code itself, which only ever reads the `register` export below.
226export const __test = {
227  sortQuestionsUrgentFirst,
228  answerArgs,
229  implementArgs,
230  filterQuestions,
231  pendingQuestions,
232  openQuestions,
233  decidedQuestions,
234  implementedQuestions,
235  questionsForView,
236  relativeAge,
237  chosenLabel,
238  loadQuestions,
239  decisionsBin,
240  resolveDecisionsFile,
241}
242
243export const register: Register = (on, options) => {
244  if (options.enabled === false) return
245
246  on('session.start', async ($, e, next) => {
247    await $.command.register({
248      name: 'decision-board',
249      description: 'Show the project’s questions in a pane; `/decision-board close` hides it',
250    })
251    // questions-board is an alias: the same board, under the name that matches
252    // bugs-board/todo-board's own naming scheme.
253    await $.command.register({
254      name: 'questions-board',
255      description: 'Alias for /decision-board: show the project’s questions in a pane; `/questions-board close` hides it',
256    })
257    await $.tool.register({
258      name: TOOL,
259      description: 'Show the project’s pending (open + decided) questions to the person, in a pane, and return them as text for the chat.',
260      inputSchema: { type: 'object', properties: {} },
261    })
262    await $.tool.register({
263      name: READ,
264      description: 'Read the project’s pending (open + decided) questions as text, without opening the pane.',
265      inputSchema: { type: 'object', properties: {} },
266    })
267    await $.tool.register({
268      name: ANSWER,
269      description: 'Record the person’s pick for an open question: sets it to decided -- pending implementation, not yet done.',
270      inputSchema: {
271        type: 'object',
272        properties: {
273          id: { type: 'string', description: 'The question id, e.g. Q3.' },
274          letter: { type: 'string', description: 'One of the question’s own option letters.' },
275        },
276        required: ['id', 'letter'],
277      },
278    })
279    await $.tool.register({
280      name: IMPLEMENT,
281      description: 'Mark a decided question as carried out in the code, recording what was done. Refused unless the question is currently decided.',
282      inputSchema: {
283        type: 'object',
284        properties: {
285          id: { type: 'string', description: 'The question id, e.g. Q3.' },
286          note: { type: 'string', description: 'What was implemented, for the record.' },
287        },
288        required: ['id'],
289      },
290    })
291    return next(e)
292  })
293
294  on('command.run', { command: 'decision-board' }, async ($, e) => {
295    if (e.args.trim().toLowerCase() === 'close') {
296      await $.ui.close({ id: PANE })
297      return { text: 'Decision board closed.' }
298    }
299    await $.ui.open({ id: PANE, title: 'Questions' })
300    return { text: 'Decision board opened.' }
301  })
302
303  // questions-board is an alias for decision-board, opening the same pane.
304  on('command.run', { command: 'questions-board' }, async ($, e) => {
305    if (e.args.trim().toLowerCase() === 'close') {
306      await $.ui.close({ id: PANE })
307      return { text: 'Decision board closed.' }
308    }
309    await $.ui.open({ id: PANE, title: 'Questions' })
310    return { text: 'Decision board opened.' }
311  })
312
313  // The agent's tools (show/read/answer/implement) deliberately never apply
314  // the pane's own priority/branch/view filters: an agent must always see
315  // the whole truth, regardless of what a person last narrowed the view to.
316  on('tool.call', { tool: `mcp__decision-board__${TOOL}` }, async $ => {
317    const root = await $.session.root()
318    const home = (await $.env.get('HOME')) ?? ''
319    const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
320    const fs: Fs = { exists: path => $.fs.exists(path), read: path => $.fs.read(path) }
321    const file = await resolveDecisionsFile(argv => $.process.run(argv), home, xdg, root)
322    const all = await loadQuestions(fs, file)
323    const pending = pendingQuestions(all)
324    await $.ui.open({ id: PANE, title: 'Questions' })
325
326    return {
327      result: [
328        `Pending questions: ${pending.length}`,
329        ...pending.map(q => `${q.id} [${q.priority}/${q.status}] ${q.title} (${q.branch})`),
330        `Implemented: ${implementedQuestions(all).length}`,
331      ].join('\n'),
332    }
333  })
334
335  // The agent's read of the pending questions. Read-only; the pane is not opened.
336  on('tool.call', { tool: `mcp__decision-board__${READ}` }, async $ => {
337    const root = await $.session.root()
338    const home = (await $.env.get('HOME')) ?? ''
339    const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
340    const fs: Fs = { exists: path => $.fs.exists(path), read: path => $.fs.read(path) }
341    const file = await resolveDecisionsFile(argv => $.process.run(argv), home, xdg, root)
342    const all = await loadQuestions(fs, file)
343    const pending = pendingQuestions(all)
344    const live = await $.ui.selection().catch(() => undefined)
345
346    return {
347      result: [
348        `Pending questions: ${pending.length}`,
349        ...pending.map(q => `${q.id} [${q.priority}/${q.status}] ${q.title} (${q.branch})`),
350        `Implemented: ${implementedQuestions(all).length}`,
351        `Highlighted in the board: ${live?.text ?? 'none'}`,
352      ].join('\n'),
353    }
354  })
355
356  // The agent's way to answer a question directly, without opening the pane
357  // or pressing a button -- the same underlying call the pane's own buttons
358  // make (answerArgs), so the two paths can never disagree about what the
359  // binary is told.
360  on('tool.call', { tool: `mcp__decision-board__${ANSWER}` }, async ($, e) => {
361    const root = await $.session.root()
362    const home = (await $.env.get('HOME')) ?? ''
363    const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
364    const id = String(e.id ?? '').trim()
365    const letter = String(e.letter ?? '').trim()
366    if (!id || !letter) return { result: 'Both id and letter are required.' }
367    const file = await resolveDecisionsFile(argv => $.process.run(argv), home, xdg, root)
368    const bin = decisionsBin(home, xdg)
369    const run = await $.process.run(answerArgs(bin, id, letter, file))
370    $.ui.invalidate('ui.render')
371    return { result: run.exitCode === 0 ? `${id} decided: ${letter}.` : (run.stderr.trim() || `decisions exited ${run.exitCode}`) }
372  })
373
374  // The agent's way to record that a decided question's pick was carried
375  // out. Deliberately agent-only: the pane itself offers no button for this
376  // -- a human picking an option is "decided", not "done", and only the
377  // agent that actually did the work can say when that is true.
378  on('tool.call', { tool: `mcp__decision-board__${IMPLEMENT}` }, async ($, e) => {
379    const root = await $.session.root()
380    const home = (await $.env.get('HOME')) ?? ''
381    const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
382    const id = String(e.id ?? '').trim()
383    const note = String(e.note ?? '').trim()
384    if (!id) return { result: 'id is required.' }
385    const file = await resolveDecisionsFile(argv => $.process.run(argv), home, xdg, root)
386    const bin = decisionsBin(home, xdg)
387    const run = await $.process.run(implementArgs(bin, id, note, file))
388    $.ui.invalidate('ui.render')
389    return { result: run.exitCode === 0 ? `${id} implemented.` : (run.stderr.trim() || `decisions exited ${run.exitCode}`) }
390  })
391
392  const REFRESH_MS = 2_000
393  let refresh: { cancel: () => void } | undefined
394
395  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
396    const { Box, Button, Text } = $.ui.resolve(e)
397    const root = await $.session.root()
398    const home = (await $.env.get('HOME')) ?? ''
399    const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
400    const fs: Fs = { exists: path => $.fs.exists(path), read: path => $.fs.read(path) }
401    const file = await resolveDecisionsFile(argv => $.process.run(argv), home, xdg, root)
402    const all = await loadQuestions(fs, file)
403
404    if (!refresh) refresh = $.clock.every(REFRESH_MS, () => $.ui.invalidate('ui.render'))
405
406    const { value: priorityFilter } = await $.state.get(priorityFilterKey)
407    const { value: branchOnly } = await $.state.get(branchOnlyKey)
408    const { value: viewValue } = await $.state.get(viewKey)
409    const view: View = viewValue === 'pending' || viewValue === 'implemented' ? viewValue : 'open'
410    const { value: lastLogged } = await $.state.get(lastLoggedKey)
411    const showBanner = !!lastLogged && Date.now() - lastLogged.at < BANNER_MS
412    // Only asks git for the current branch when the toggle actually needs it:
413    // every other render costs one file read and nothing else.
414    let activeBranch: string | null = null
415    if (branchOnly) {
416      const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).catch(() => undefined)
417      activeBranch = head && head.exitCode === 0 ? head.stdout.trim() : null
418    }
419    const inView = questionsForView(all, view)
420    const questions = filterQuestions(inView, { priority: priorityFilter ?? null, branch: activeBranch })
421    const now = Date.now()
422
423    const answer = async (id: string, letter: string) => {
424      // Reuses the `file` resolved once above for this render -- a second
425      // resolve-path call here would double the process cost per press.
426      const bin = decisionsBin(home, xdg)
427      const run = await $.process.run(answerArgs(bin, id, letter, file))
428      if (run.exitCode === 0) {
429        await update($, lastLoggedKey, () => ({ id, at: Date.now() }))
430      }
431      $.ui.invalidate('ui.render')
432    }
433
434    const HEADINGS: Record<View, string> = { open: 'Open questions', pending: 'Pending questions', implemented: 'Implemented questions' }
435    const heading = HEADINGS[view]
436    // Said once per view, not once per row -- a row's own line stays just
437    // "picked X", since repeating the explanation on every question in a
438    // long pending list is noise once the person already knows what the
439    // view means.
440    const SUBTITLES: Partial<Record<View, string>> = {
441      pending: 'Decided; the agent implements these and marks them, not the person.',
442    }
443
444    return (
445      <Box flexDirection="column">
446        <Box flexDirection="row" justifyContent="space-between">
447          <Text bold inverse>
448            {heading}
449          </Text>
450          <Button role="dismiss" onPress={() => $.ui.close({ id: PANE })}>
451            Close
452          </Button>
453        </Box>
454        {SUBTITLES[view] && <Text dimColor>{SUBTITLES[view]}</Text>}
455        {showBanner && (
456          <Text color="#ffa500" bold>
457            {`Decision logged (${lastLogged!.id}). Please remind Claude in chat -- sadly this can't be automated.`}
458          </Text>
459        )}
460        <Text> </Text>
461        <Box flexDirection="row">
462          <Text dimColor>View: </Text>
463          <Button
464            variant={view === 'open' ? 'primary' : undefined}
465            onPress={() => update($, viewKey, () => 'open')}
466          >
467            Open
468          </Button>
469          <Text> </Text>
470          <Button
471            variant={view === 'pending' ? 'primary' : undefined}
472            onPress={() => update($, viewKey, () => 'pending')}
473          >
474            Pending
475          </Button>
476          <Text> </Text>
477          <Button
478            variant={view === 'implemented' ? 'primary' : undefined}
479            onPress={() => update($, viewKey, () => 'implemented')}
480          >
481            Implemented
482          </Button>
483        </Box>
484        <Box flexDirection="row">
485          <Text dimColor>Priority: </Text>
486          {PRIORITY_ORDER.map(p => (
487            <Box key={p} flexDirection="row">
488              <Button
489                variant={priorityFilter === p ? 'primary' : undefined}
490                onPress={() => update($, priorityFilterKey, () => (priorityFilter === p ? null : p))}
491              >
492                {p}
493              </Button>
494              <Text> </Text>
495            </Box>
496          ))}
497          <Button
498            variant={branchOnly ? 'primary' : undefined}
499            onPress={() => update($, branchOnlyKey, () => !branchOnly)}
500          >
501            this branch only
502          </Button>
503        </Box>
504        <Text> </Text>
505        {inView.length === 0 && (
506          <Text dimColor>
507            {{ open: 'No open questions.', pending: 'Nothing decided yet.', implemented: 'Nothing implemented yet.' }[view]}
508          </Text>
509        )}
510        {inView.length > 0 && questions.length === 0 && <Text dimColor>No questions match this filter.</Text>}
511        {questions.map(q => (
512          <Box key={q.id} flexDirection="column">
513            <Text bold color={PRIORITY_COLOR[q.priority]}>
514              {`${q.id}  [${q.priority}${q.status === 'decided' ? ' · decided' : ''}]  ${q.title}`}
515            </Text>
516            <Text italic dimColor>
517              {`   on ${q.branch || 'no branch recorded'} · asked ${relativeAge(q.created_at, now) || 'at an unknown time'}`}
518            </Text>
519            {q.status === 'open' && (
520              <Box flexDirection="column">
521                {q.options.map(option => (
522                  <Box key={option.letter} flexDirection="row">
523                    <Button onPress={() => answer(q.id, option.letter)}>{`${option.letter}: ${option.label}`}</Button>
524                  </Box>
525                ))}
526              </Box>
527            )}
528            {q.status === 'decided' && (
529              <Text dimColor>{`   picked ${chosenLabel(q)}`}</Text>
530            )}
531            {q.status === 'implemented' && (
532              <Text dimColor>
533                {`   picked ${chosenLabel(q)}${q.resolution ? ` · ${q.resolution}` : ''} · implemented ${relativeAge(q.updated_at, now) || 'at an unknown time'}`}
534              </Text>
535            )}
536            <Text> </Text>
537          </Box>
538        ))}
539      </Box>
540    )
541  })
542}
543
types/index.d.ts 22 lines
1// The person's own priority/branch filters and Open/Pending/Implemented view
2// toggle for the pane, kept across redraws the same way ci-board keeps its
3// own browsing state: null means "unset, default to open" for `view`,
4// the same way null/false means "no filter" for the others -- the pane
5// always has a definite state to draw from. `register-board`'s own
6// types/index.d.ts has nothing to augment here (it keeps no `$.state` at
7// all); this mod now does.
8declare module 'claude-code' {
9  interface PluginState {
10    'decision-board': {
11      priorityFilter: string | null
12      branchOnly: boolean
13      view: 'open' | 'pending' | 'implemented' | null
14      lastLogged: { id: string; at: number } | null
15    }
16  }
17  interface McpToolInputs {
18    'mcp__decision-board__answer_decision_board': { id: string; letter: string }
19    'mcp__decision-board__implement_decision_board': { id: string; note?: string }
20  }
21}
22