flightwake's Claude Code layer: STATE in the system prompt, a quiet status band, a session flight log, trap tripwires and an opt-in role guard. Reads…

<!-- Primary edition (English). Translations: README.zh-TW.md / README.zh-CN.md / README.ja.md — editing any edition obligates syncing the others. -->
Records are the contrail your work naturally leaves behind — not a flight plan you must file before takeoff.
An ultra-lightweight work-recording framework for strong AI coding agents (Claude Fable 5 generation and beyond). Zero runtime dependencies, pure Markdown, everything lives in git.

A real cold start on this very repo (recorded live, zh-TW install): one command, two file reads, and a fresh session reports exactly where the last one left off.
cd your-repo
npx flightwake setup # guided install: a few questions, shows every path it will write, installs after you confirm
setup needs a terminal. It checks git first (offering git init if the directory isn't a repo — default No, run only after the final confirmation), then asks language, agents (the detected ones if the folder already has CLAUDE.md / AGENTS.md / GEMINI.md; otherwise it asks which tools you use — pick one or more, nothing preselected), optional add-ons (each default No: the bottom gauge, the Claude Code mod, roles, Orca collaboration; the mod question is only asked when Claude Code is picked) and repo type (code / notes), lists every path it will write, and asks Proceed? [Y/n] — Enter installs; n, EOF or Ctrl-C writes nothing. If flightwake is already installed it only offers an in-place upgrade (update). It installs through the same path as init, then runs doctor and prints next steps. Flags you pass on the command line answer their question; --private is flag-only and is never asked.
Non-interactive form — npx flightwake init [flags] (a bare npx flightwake does the same) never asks anything: use it for automation, agents, CI, and when you already know what you want:
npx flightwake init --statusline # English (default) + the bottom gauge
npx flightwake init --statusline --agents=claude,codex,gemini # all three agents at once (Claude Code + Codex + Gemini CLI)
npx flightwake update # upgrade an existing install in place (keeps your options: lang/statusline/private)
Pick your language (non-interactive form; setup asks it for you) — installed templates, skills, and all CLI/gauge output follow it. There is no auto-detection: a terminal's LANG and the OS locale routinely disagree, and a confident wrong guess is worse than a stated default. Copy the line you want:
| Language | Fresh install | Already installed in another language |
|---|---|---|
| English | npx flightwake init --statusline | npx flightwake init --lang=en --force --statusline |
| 繁體中文 | npx flightwake init --lang=zh-TW --statusline | npx flightwake init --lang=zh-TW --force --statusline |
| 简体中文 | npx flightwake init --lang=zh-CN --statusline | npx flightwake init --lang=zh-CN --force --statusline |
| 日本語 | npx flightwake init --lang=ja --statusline | npx flightwake init --lang=ja --force --statusline |
Switching languages is safe: --force replaces only framework-owned files (templates, skills, hooks, the marker block). Your STATE / DECISIONS / TRAPS / records are never touched — they stay in whatever language you wrote them. Drop --statusline from any line if you don't want the gauge. Add --agents=claude,codex,gemini (any subset) to install for those agents explicitly — without it, init installs for whichever instruction files already exist (CLAUDE.md / AGENTS.md / GEMINI.md).
Don't hand-translate the installed files. The marker records which language you installed, so the next update refreshes them from that language's source and your edits disappear. Rerun init with --lang instead; if you already did hand-edit, init/update will now name each file it overwrote.
init creates .flightwake/ (templates + Stop hook), copies 4 skills into .claude/skills/, merges the Stop hook into .claude/settings.json, and appends the trigger-obligation table (wrapped in <!-- flightwake:begin/end --> markers) to detected agent instruction files (CLAUDE.md / AGENTS.md / GEMINI.md — whichever exist; if none, it creates AGENTS.md; --agents=claude,codex,gemini selects explicitly). Each detected platform gets the same skills and hook in its own dialect: Codex and Gemini CLI read the skills from .agents/skills/fw-*, the STATE check goes into .codex/hooks.json (Stop) or .gemini/settings.json (AfterAgent), and the table says $fw-coldstart to Codex, /fw-coldstart to Claude Code, and the bare skill name to Gemini. Codex asks you to trust the repo hook once on first run. Pure file copying, zero runtime dependencies (Node ≥18 used only at install time and by the hooks). User data (STATE/DECISIONS/TRAPS) is never overwritten; --force only updates framework-owned files. update re-detects what you installed and refreshes it from the latest version.
/fw-coldstart — it notices STATE is still the unfilled template and writes the first STATE from the repo itself (health is never guessed green: it is yellow until something was actually verified)git add .flightwake .claude CLAUDE.md && git commitYou (and the model) only need to remember one thing: start work with /fw-coldstart; the model triggers every other obligation itself — the obligation table is already in the instruction file, and strong models both read it and honor it. A typical session:
You: /fw-coldstart
Model: (reads STATE + the latest record, ~1 minute)
"Last session got to X, health green, next entry point is Y.
Unverified changes: none. Pick up from Y?"
You: Yes, go
Model: (starts working directly. Makes a decision that closes off options →
one line appended to DECISIONS; hits a non-obvious trap → /fw-trap)
You: Wrap up
Model: (/fw-record: writes the flight record, updates STATE, runs the
sensitive-info self-check)
Forgot to wrap up? When STATE lags ≥3 commits, the Stop hook blocks once before the session ends to remind you (it also nags when STATE claims health=green but the latest record carries no test evidence); --ci brings the same gate to other agents and human collaborators. Honest edges of the net: the lag counts human commits only — bot commits (dependabot[bot], renovate[bot], …) are excluded, because a dependency bump never makes STATE wrong and the bot's own PR could never satisfy the gate. A session that commits nothing (research, ops work) or a squash/rebase flow slips under it — the net catches forgetting; it doesn't replace the session-end obligation. For multi-session construction, say "handoff" before stopping so the model runs /fw-handoff.
Whether STATE's health is honest (green/yellow/red). The framework has a single quality metric: how long it takes a fresh session to reach a safe takeover after /fw-coldstart — if that takes more than 5 minutes, your records are degrading. Everything else — record count, format compliance — doesn't matter.
When that light comes on, you don't do the maintenance yourself. Say: "this cold start took X minutes — diagnose what's slow and compact." The model comes back with a diagnosis (STATE too long? last session never wrapped up? stale TRAPS/DECISIONS entries?) and an item-by-item plan — which entries to mark superseded and why, which to merge — and you approve with one word. Facts work better than pressure: "over 5 minutes means the next session will fumble the takeover" is a prompt the model can reason about; "this is serious!" is not.
This repo dogfoods its own framework: .flightwake/ contains the real STATE, DECISIONS, and records — every step from the gap list to the open-source launch is recorded there. That's exactly what will grow in your repo after installing.
New to working with a strong model? docs/workflow.md is a stage map of what you do and what to say to the model at each point — beginner main line, advanced folds for Claude Code veterans. (繁體中文版:workflow.zh-TW.md)
Using more than one model on the same repo? docs/multi-agent.md shows how Claude Code, Codex, and Gemini CLI share one .flightwake/ — what init installs for each, how to invoke the skills in each tool, and the wrap-up → commit → cold-start loop that makes the handover identical whichever model wrote last. (繁體中文版:multi-agent.zh-TW.md)
Running a team of agents (a project manager, a tech lead, a coder, a reviewer)? docs/roles.md covers flightwake roles (opt-in, v0.14.0+): the agent recommends a set of roles for your project, you preview and customize them, and each role is written into the instruction file its agent re-reads at every session start — so nobody forgets their job after /clear, across repos if your team spans several. (繁體中文:roles.zh-TW.md · 简体中文:roles.zh-CN.md · 日本語:roles.ja.md)
A Fable 5-class model doesn't need to be taught how to do the work — but there are four things no model can do however strong it gets, because they are structural and don't disappear as models improve:
So flightwake supplements persistence and discipline, not intelligence. Its ancestor in spirit is GSD: GSD is navigation (turn-by-turn guidance for every step); flightwake is a dashcam + warning lights + road signs — strong models drive themselves, so the framework only does three things:
records/, DECISIONS.md, TRAPS.md)STATE.md and takes over safely within 2 minutesThe origin was a real three-day session (2026-07-15~17: two repos, 19 commits, 4 cron jobs, 2 deep bug fixes — no upfront planning, zero derailment). It proved a strong model needs no navigation — but everything it left behind to make the next session possible (SUMMARY/CONTEXT/memory files) was improvised on the spot. flightwake turns that improvisation into an installable convention.
GSD is stage-driven (research→plan→execute→verify gates); flightwake is trigger-driven (events create obligations):
| Trigger event | Obligation | Tool |
|---|---|---|
| Starting to touch a repo | Read STATE + the latest record first | /fw-coldstart |
| Making a decision that closes off other options | One line into DECISIONS (append-only, with the why) | write directly |
| Hitting a non-obvious trap | One entry into TRAPS | /fw-trap |
| Touching schema / touching prod / ~3+ commits | Wrap up with a record | /fw-record |
| Work will span sessions | Write handoff/CONTEXT before stopping (not before starting) | /fw-handoff |
| Session about to close | Update STATE's position and next-step entry point | part of /fw-record |
Escalation rule (the opposite of GSD): by default everything is quick — just start working; only "construction spanning multiple sessions" escalates to a phase (one CONTEXT file; plan decomposition is left to the model's in-the-moment judgment).
your-repo/
├── .flightwake/
│ ├── STATE.md # where we are now, next-step entry (always short, always current)
│ ├── DECISIONS.md # append-only decision log (one line per decision, with the why)
│ ├── TRAPS.md # trap registry (OKF-style frontmatter entries)
│ ├── TEMPLATE-record.md # flight-record template
│ ├── hooks/state-check.mjs # Stop hook: reminds you to wrap up when STATE lags ≥3 commits
│ └── records/ # flight records (one per meaningful wrap-up)
├── .claude/skills/fw-*/ # the four skills (Claude Code)
├── .claude/settings.json # init merges the Stop hook config here
├── .agents/skills/fw-*/ # the same four skills for Codex / Gemini CLI (only when AGENTS.md / GEMINI.md is detected)
├── .codex/hooks.json # Codex Stop hook (only when AGENTS.md is detected)
└── .gemini/settings.json # Gemini CLI AfterAgent hook (only when GEMINI.md is detected)
The skills and hooks are convenience sugar per platform — the same four skills and the same check script, installed where Claude Code, Codex, and Gemini CLI each look for them; .flightwake/ itself is plain Markdown in git, so every agent (and every human) reads and writes the same state. Any other agent that reads the instruction file can follow the same trigger obligations by hand. Coexists with an existing GSD .planning/ (old records become historical archives).
--private keeps records local-only, out of git: every write is registered in .git/info/exclude (purely local — no trace left in the repo), the hook goes into .claude/settings.local.json, and the obligation table goes into CLAUDE.local.md (git-tracked instruction files are never touched). The cost: records aren't shared with the repo, and a fresh clone needs init --private again — "in git, shared with the repo" is flightwake's default and reason to exist; --private is the escape hatch for personal use inside someone else's repo.
doctor (npx flightwake doctor) is a read-only, no-network check of the install structure: git and git root, Node ≥18, .flightwake/, STATE (unfilled template fields are a warning), latest_record, marker blocks and their version/lang/profile consistency, skills, hook registration (valid JSON, exact command, correct event — Stop for Claude Code/Codex, AfterAgent for Gemini CLI — no duplicates, script exists), that --private excludes are actually in effect, and the status of optional add-ons. Each line is ok / warning / fail; exit code 1 on any failure. It verifies structure only, not that hooks fire at runtime (whether Codex trusts the hook path can't be checked — doctor prints a hint instead). Writes nothing.
--profile=code|notes (default code) picks the obligation table. notes is for repos that aren't code (writing, research, notes): it drops "tests green + typecheck clean", "prod verification evidence", and the schema/prod wrap-up trigger, and keeps cold start, decisions, traps, handoff, the ≥3-commit wrap-up, confirming destructive operations, and an honest STATE at session end. The same files are installed. The profile is stored in the marker (profile=notes); update keeps it, and update --profile=code switches back.
--orca (opt-in; also a setup question, offered only when Orca is detected) adds a marked block to each active platform's instruction file: use visible Orca tabs — not hidden background runs — for cross-agent discussion and review, plus a one-writer review protocol (the agent that was asked to review writes no record and doesn't touch STATE; the asker records the conclusions it adopts). uninstall removes it; update refreshes it only where installed.
--mod (opt-in; also a setup question, asked only when Claude Code is among the agents) installs the flightwake-mod Claude Code mod into .claude/skills/flightwake-mod/: five features (state injection at session start, a band above the prompt, a session flight log, a TRAPS tripwire, and an off-by-default role guard), each with its own switch. It needs Claude Code 2.1.287+, an accepted folder trust prompt and a session started at the repo root; it never writes your records. Without Claude Code among the agents, init --mod prints a note and skips it; an existing folder is skipped unless --force. update refreshes it only where installed; uninstall removes the files it shipped and keeps (and lists) anything you added in that folder. Details: docs/mod.md.
--git-init makes init create the git repo when the directory isn't one — explicit flag only; without it, init stops and tells you. Both init and setup check that git is installed first and print per-platform install hints if not.
uninstall reverses init's fixed write scope: removes the skills' and the framework's own files (only what it shipped — anything you added inside a skill folder, or a directory where a shipped file was, is kept and listed), extracts flightwake's Stop hook from settings (your other hooks stay untouched), and strips the marker blocks from instruction files and .git/info/exclude (files created by flightwake are deleted once emptied). .flightwake/ is user data and is kept by default; only uninstall --purge deletes it too.
Monorepo policy: one install per repo, at the git root. Work is session-shaped — a session routinely spans multiple packages, and records follow the session; per-subdirectory installs would shred one stretch of work into fragmented records and turn "which STATE do I read?" into a new cold-start ambiguity. Running init in a subdirectory stops and points you to the root. Submodules have their own .git and count as independent repos. If a high-traffic multi-team monorepo sees false positives from the CI staleness check, tune --threshold first.
Wrap up your current milestone first, then:
npx flightwake init — coexists with .planning/; nothing is deleted.planning/ for the current state and initialize .flightwake/STATE.md with /fw-record — unfinished items go into the next-step entries. From now on .planning/ is a historical archive; don't update it."npx flightwake init --statusline puts a persistent gauge at the bottom of Claude Code:
✈️ flightwake │ ●green · STATE 2c behind │ ▓▓░░░░░░░░ 23%
Health color (the one thing you watch), STATE staleness (same rev-list logic as the Stop hook — but as a live gauge instead of an exit-time reminder), and context usage. The gauge also tells you the next command for the current state — session just started → → 開工先 /fw-coldstart; STATE ≥3 commits behind → → /fw-record; context running hot → → /fw-record → /clear → /fw-coldstart; all healthy → silence. It never overwrites an existing statusline (a single-value setting), and repo-level config takes precedence over user-level, so it coexists with tools that set a global one.
Note: a plain npx flightwake init does not install the gauge — it's opt-in. Already ran init without it? Running npx flightwake init --statusline again just adds the gauge (everything else is skipped as already installed); the bar appears in the next Claude Code session.
The gauge also tells you when a newer flightwake exists (→ v0.9.1 available: npx flightwake update) — shown only when nothing more urgent is up. The check is an anonymous GET to the npm registry at most once per 24h, cached in the OS temp dir, always in a background process (rendering never waits on the network). Opt out with FLIGHTWAKE_NO_UPDATE_CHECK=1.
The hook fires only inside Claude Code, Codex, and Gemini CLI sessions; to extend the "STATE must not lag" discipline to other agents and human collaborators, run the same script in CI — it fails when STATE lags HEAD by ≥3 commits (tunable via --threshold=N):
# .github/workflows/flightwake.yml (example; pin actions to SHAs per your repo's conventions)
name: flightwake
on: [push, pull_request]
permissions:
contents: read
jobs:
state-fresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # rev-list needs full history to count the lag
- uses: actions/setup-node@v7
with:
node-version: 24
- run: node .flightwake/hooks/state-check.mjs --ci
flightwake will not write a workflow into your repo — .github/workflows/ is permission-sensitive and outside the "fixed write scope" promise; copy the example yourself.
Claude Code memory: persistent memory has the same shape as flightwake (frontmatter + [[links]]) but lives on a different layer — memory is single-machine, single-person; flightwake's files go into git and travel with the repo to teammates, CI, and any agent. Repo facts (traps, decisions, state) go to flightwake; personal preferences and cross-project habits go to memory. Never write the same fact in both places — with one deliberate exception: a trap that isn't repo-specific (platform/language layer) lives in both, because the repo's registry must stay self-contained while your other repos need the warning too (one copy per scope is division of labor, not duplication).
Google OKF: OKF manages the knowledge layer (system facts: schemas, metric definitions, code mappings); flightwake manages the process layer (what happened, why, where we are now). flightwake's knowledge-shaped artifacts adopt OKF conventions (YAML frontmatter + [[links]]) — naturally compatible on the shared "plain Markdown + frontmatter" substrate.
git (no shell) for read-only queries.init only touches .flightwake/, .claude/skills/fw-*, .claude/settings.json, the marker blocks inside agent instruction files (including the Orca block, only when you opted in), ~/.flightwake/registry.json (init/update write it; uninstall removes this repo's entry), .claude/skills/fw-roles / .agents/skills/fw-roles (only when you opted into roles), .claude/skills/flightwake-mod/ (only when you opted into the mod; uninstall removes the shipped files and keeps anything you added there, and with --private it is added to the exclude block), and — when Codex / Gemini CLI is dehooks/register.ts 38 lines1/**
2 * flightwake-mod — Claude Code's add-on layer over flightwake's Markdown records (docs/plans/mods.md).
3 * register only assembles: each feature lives in its own module under features/ and is switched by its
4 * userConfig field. A feature that throws while registering is dropped alone; at run time the engine skips a
5 * failing hook and the chain goes on, so one feature's failure never reaches the others or the person's work.
6 */
7import type { PluginOptions, Register } from 'claude-code'
8
9import { registerStateInject } from './features/state-inject'
10import { registerBand } from './features/band'
11import { registerRecorder } from './features/recorder'
12import { registerTripwire } from './features/tripwire'
13import { registerRoleGuard } from './features/role-guard'
14import { registerStatus } from './features/status'
15
16/** userConfig field → default, mirrored from .claude-plugin/plugin.json (F5 is opt-in). */
17export const DEFAULTS = { stateInject: true, band: true, recorder: true, tripwire: true, roleGuard: false } as const
18
19export const isEnabled = (options: PluginOptions, key: keyof typeof DEFAULTS): boolean =>
20 typeof options[key] === 'boolean' ? options[key] === true : DEFAULTS[key]
21
22// Each call is spelled out (not looped over a table): the engine's loader only accepts `on` passed to a function
23// imported by name. Each is wrapped so a feature that throws while registering stays off alone.
24export const register: Register = (on, options) => {
25 if (isEnabled(options, 'stateInject')) try { registerStateInject(on) } catch {} // F1
26 if (isEnabled(options, 'band')) try { registerBand(on) } catch {} // F2
27 if (isEnabled(options, 'recorder')) try { registerRecorder(on) } catch {} // F3
28 if (isEnabled(options, 'tripwire')) try { registerTripwire(on) } catch {} // F4
29 if (isEnabled(options, 'roleGuard')) try { registerRoleGuard(on) } catch {} // F5
30 // /fw-mod: not a switch — it is how the switches are seen
31 try {
32 registerStatus(on, {
33 stateInject: isEnabled(options, 'stateInject'), band: isEnabled(options, 'band'), recorder: isEnabled(options, 'recorder'),
34 tripwire: isEnabled(options, 'tripwire'), roleGuard: isEnabled(options, 'roleGuard'),
35 })
36 } catch {}
37}
38hooks/features/state-inject.ts 162 lines1/**
2 * F1 STATE snapshot into the system prompt (docs/plans/mods.md, revised; DECISIONS 2026-10-05).
3 * One snapshot per session id, taken on session.start (pre-warm) or lazily on the first prompt.compose, then
4 * reused: the section never changes mid-session, so the prompt cache behind it stays valid. /clear (session.end
5 * reason 'clear') drops it; the next compose retakes it under the new id. Read-only; a failure injects nothing.
6 */
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, On } from 'claude-code'
9
10import { M, STATE_REL, fwContext, isUninitializedState, readRel } from '../lib/core'
11import type { Io, Lang } from '../lib/core'
12
13const SECTION_ID = 'flightwake-mod:state'
14/** DECISIONS 2026-10-05: STATE text over this many characters is injected condensed. */
15const LIMIT = 6000
16
17const snapshotRef = atom({ plugin: 'flightwake-mod', key: 'stateSnapshot' } as const, null)
18
19type Snap = { sessionId: string; text: string | null }
20
21// Local copy of IO_OF_TEMPLATE (hooks/lib/core.ts): `$` must not cross an import.
22function ioOf($: EngineInterface): Io {
23 return {
24 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
25 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
26 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
27 read: async (p) => {
28 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
29 },
30 git: async (args, cwd) => {
31 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
32 },
33 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
34 }
35}
36
37/** STATE split into its frontmatter block and its top-level "# " sections (comments and fenced code ignored). */
38function splitState(text: string): { front: string; sections: string[] } {
39 const lines = text.split(/\r?\n/)
40 let i = 0
41 let front: string[] = []
42 if (lines[0]?.trim() === '---') {
43 const end = lines.findIndex((l, k) => k > 0 && l.trim() === '---')
44 if (end > 0) { front = lines.slice(0, end + 1); i = end + 1 }
45 }
46 const sections: string[][] = []
47 let inComment = false
48 let inFence = false
49 for (; i < lines.length; i++) {
50 const line = lines[i] ?? ''
51 let isHeading = false
52 if (inComment) {
53 if (line.includes('-->')) inComment = false
54 } else if (inFence) {
55 if (/^\s*(```|~~~)/.test(line)) inFence = false
56 } else if (/^\s*(```|~~~)/.test(line)) {
57 inFence = true
58 } else if (line.trimStart().startsWith('<!--')) {
59 if (!line.includes('-->')) inComment = true
60 } else if (/^# /.test(line)) {
61 isHeading = true
62 }
63 if (isHeading) sections.push([line])
64 else sections[sections.length - 1]?.push(line)
65 }
66 return { front: front.join('\n'), sections: sections.map((s) => s.join('\n').replace(/\s+$/, '')) }
67}
68
69/** `text` cut at a line boundary to at most `max` characters (the "…" marker included); unchanged when it fits. */
70function trimLines(text: string, max: number): string {
71 if (text.length <= max) return text
72 const cut = text.slice(0, Math.max(0, max - 2))
73 const nl = cut.lastIndexOf('\n')
74 return `${nl > 0 ? cut.slice(0, nl) : cut}\n…`
75}
76
77/** The condensed STATE: frontmatter (health), the 2nd and 3rd top-level sections, each trimmed if still over LIMIT. */
78function condense(text: string): string {
79 const { front, sections } = splitState(text)
80 const kept = [sections[1], sections[2]].filter((s): s is string => typeof s === 'string')
81 const frontKept = front ? trimLines(front, 1500) : ''
82 const join = (b: string[]) => [frontKept, ...b].filter(Boolean).join('\n\n')
83 if (join(kept).length <= LIMIT) return join(kept)
84 const budget = LIMIT - frontKept.length - 2 * (kept.length + 1)
85 if (kept.length === 0) return join([trimLines(sections.join('\n\n') || text, Math.max(200, budget))])
86 const each = Math.max(200, Math.floor(budget / kept.length))
87 return join(kept.map((s) => trimLines(s, each)))
88}
89
90function snapshotText(lang: Lang, state: string): string {
91 if (isUninitializedState(state)) {
92 return M(lang, {
93 en: 'flightwake: .flightwake/STATE.md is not initialized yet. Run /fw-coldstart first.',
94 'zh-TW': 'flightwake:.flightwake/STATE.md 尚未初始化,請先執行 /fw-coldstart。',
95 'zh-CN': 'flightwake:.flightwake/STATE.md 尚未初始化,请先执行 /fw-coldstart。',
96 ja: 'flightwake: .flightwake/STATE.md はまだ初期化されていません。先に /fw-coldstart を実行してください。',
97 })
98 }
99 const header = M(lang, {
100 en: 'flightwake: this is .flightwake/STATE.md as of the last wrap-up, a snapshot taken at session start. Check the git state before acting on it. /fw-coldstart still checks how far STATE lags and reads the latest record.',
101 'zh-TW': 'flightwake:以下是 .flightwake/STATE.md 在上次收尾時的內容,於 session 開始時取的快照。動手前請先核對 git 狀態。落後量檢查與最新 record 的閱讀仍由 /fw-coldstart 負責。',
102 'zh-CN': 'flightwake:以下是 .flightwake/STATE.md 在上次收尾时的内容,于 session 开始时取的快照。动手前请先核对 git 状态。落后量检查与最新 record 的阅读仍由 /fw-coldstart 负责。',
103 ja: 'flightwake: 以下は前回の締め時点の .flightwake/STATE.md で、セッション開始時に取ったスナップショットです。作業の前に git の状態を確認してください。遅れの確認と最新 record の読み込みは引き続き /fw-coldstart が行います。',
104 })
105 if (state.length <= LIMIT) return `${header}\n\n${state.replace(/\s+$/, '')}`
106 const n = state.length
107 const note = M(lang, {
108 en: `(condensed; full file at ${STATE_REL} (${n} characters, over ${LIMIT}); read it when needed. Consider compacting STATE.)`,
109 'zh-TW': `(已精簡;完整內容見 ${STATE_REL}(${n} 字元,超過 ${LIMIT});需要時請讀取原檔。建議壓實 STATE。)`,
110 'zh-CN': `(已精简;完整内容见 ${STATE_REL}(${n} 字符,超过 ${LIMIT});需要时请读取原文件。建议压实 STATE。)`,
111 ja: `(要約済み。全文は ${STATE_REL}(${n} 文字、上限 ${LIMIT} 超)にあります。必要なときに読んでください。STATE の圧縮を検討してください。)`,
112 })
113 return `${header}\n\n${condense(state)}\n\n${note}`
114}
115
116async function takeText(io: Io): Promise<string | null> {
117 const ctx = await fwContext(io)
118 if (!ctx) return null
119 const state = await readRel(io, ctx.root, STATE_REL)
120 return state === null ? null : snapshotText(ctx.lang, state)
121}
122
123/** The snapshot for the current session id: kept when it already exists, otherwise taken now and stored. */
124async function ensureSnapshot($: EngineInterface): Promise<Snap | null> {
125 const io = ioOf($)
126 const id = await io.sessionId()
127 if (!id) return null
128 const cur = (await read($, snapshotRef)) as Snap | null
129 if (cur && cur.sessionId === id) return cur
130 const fresh: Snap = { sessionId: id, text: await takeText(io) }
131 // A concurrent taker (session.start vs. the first compose) may have stored one meanwhile: that one wins.
132 await update($, snapshotRef, (c) => ((c as Snap | null)?.sessionId === id ? c : fresh))
133 return ((await read($, snapshotRef)) as Snap | null) ?? fresh
134}
135
136export function registerStateInject(on: On): void {
137 on('session.start', {}, async ($, e, next) => {
138 const r = await next(e)
139 try { await ensureSnapshot($) } catch {}
140 return r
141 })
142
143 on('session.end', {}, async ($, e, next) => {
144 if (e.reason === 'clear') {
145 try { await update($, snapshotRef, () => null) } catch {}
146 }
147 return next(e)
148 })
149
150 on('prompt.compose', {}, async ($, e, next) => {
151 const r = await next(e)
152 if (e.traits.includes('bare')) return r
153 try {
154 const snap = await ensureSnapshot($)
155 if (!snap || !snap.text) return r
156 return { sections: [...r.sections.filter((s) => s.id !== SECTION_ID), { id: SECTION_ID, text: snap.text, scope: 'session' as const }] }
157 } catch {
158 return r
159 }
160 })
161}
162hooks/features/band.ts 217 lines1/**
2 * F2 status band above the prompt + one context toast. Contract: docs/plans/mods.md (revised) and hooks/lib/core.ts.
3 *
4 * The FwBandView ($.state bandView) is computed OUTSIDE rendering (session.start, turn.complete, and after a Bash
5 * tool call that reports a commit or branch operation); the ui.render hook only reads it and draws, never runs git.
6 * Hints point only at flightwake commands (same wording family as hooks/statusline.mjs); no update check, no network.
7 * When the legacy statusline.mjs is the effective statusLine every field would duplicate it, so the band stays
8 * quiet and only the one-time context toast remains. Without it the band IS the gauge: always drawn, always with the
9 * context percent when Claude Code reports one (2026-10-05, replacing "silent while all is well" — someone with the
10 * mod and no gauge otherwise never saw their context use). The mod never edits settings.
11 */
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, On, RenderElement } from 'claude-code'
14
15import { fwContext, healthOf, isUninitializedState, joinPath, legacyStatuslineActive, M, STATE_REL, stateLag } from '../lib/core'
16import type { Io, Lang } from '../lib/core'
17import type { FwBandView } from '../../types/index'
18
19const bandView = atom({ plugin: 'flightwake-mod', key: 'bandView' } as const, null)
20const bandToastSession = { plugin: 'flightwake-mod', key: 'bandToastSession' } as const
21
22function ioOf($: EngineInterface): Io {
23 return {
24 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
25 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
26 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
27 read: async (p) => {
28 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
29 },
30 git: async (args, cwd) => {
31 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
32 },
33 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
34 }
35}
36
37const HOT = 80
38const WARM = 60
39const LAG_HINT = 3
40
41function hintFor(lang: Lang, v: { health: string; contextPercent: number | null; behind: number | null; justOpened: boolean; isUninitialized?: boolean }): string | null {
42 if (v.isUninitialized) return M(lang, {
43 en: 'STATE not initialized yet — run /fw-coldstart',
44 'zh-TW': 'STATE 尚未初始化——先跑 /fw-coldstart',
45 'zh-CN': 'STATE 尚未初始化——先跑 /fw-coldstart',
46 ja: 'STATE が未初期化——まず /fw-coldstart',
47 })
48 if (v.health === 'yellow' || v.health === 'red') return M(lang, {
49 en: 'handle unverified items before stacking new work (read STATE)',
50 'zh-TW': '先處理未驗證項再疊新工作(讀 STATE)',
51 'zh-CN': '先处理未验证项再叠新工作(读 STATE)',
52 ja: '未検証の項目を片付けてから新しい作業を積む(STATE を読む)',
53 })
54 if (v.contextPercent !== null && v.contextPercent >= HOT) return M(lang, {
55 en: '/fw-record → /clear → /fw-coldstart',
56 'zh-TW': '/fw-record 收尾 → /clear → /fw-coldstart 接手',
57 'zh-CN': '/fw-record 收尾 → /clear → /fw-coldstart 接手',
58 ja: '/fw-record で締め → /clear → /fw-coldstart で引き継ぎ',
59 })
60 if (v.behind !== null && v.behind >= LAG_HINT) return M(lang, {
61 en: '/fw-record to wrap up',
62 'zh-TW': '/fw-record 收尾',
63 'zh-CN': '/fw-record 收尾',
64 ja: '/fw-record で締める',
65 })
66 if (v.justOpened) return M(lang, {
67 en: 'start with /fw-coldstart',
68 'zh-TW': '先跑 /fw-coldstart 接手',
69 'zh-CN': '先跑 /fw-coldstart 接手',
70 ja: 'まず /fw-coldstart で引き継ぐ',
71 })
72 return null
73}
74
75/** Pure: the view for a set of facts. Language only shapes the hint. */
76function viewOf(lang: Lang, f: {
77 health: FwBandView['health']
78 lag: FwBandView['lagKind']
79 behind: number | null
80 contextPercent: number | null
81 isLegacyGaugeActive: boolean
82 justOpened: boolean
83 isUninitialized?: boolean
84}): FwBandView {
85 const hint = hintFor(lang, { health: f.health, contextPercent: f.contextPercent, behind: f.behind, justOpened: f.justOpened, isUninitialized: f.isUninitialized })
86 return {
87 isLegacyGaugeActive: f.isLegacyGaugeActive,
88 health: f.health,
89 lagKind: f.lag,
90 behind: f.behind,
91 contextPercent: f.contextPercent,
92 hint,
93 // Quiet only when the bottom gauge already shows all of this; otherwise the band stands in for the gauge
94 isQuiet: f.isLegacyGaugeActive,
95 lang,
96 }
97}
98
99type Computed = { view: FwBandView; lang: Lang; sessionId: string }
100
101/** Reads the world and builds the view; null when flightwake is not set up here. A template STATE → health unknown + coldstart hint. */
102async function compute($: EngineInterface): Promise<Computed | null> {
103 const io = ioOf($)
104 const ctx = await fwContext(io)
105 if (ctx === null) return null
106 const text = await io.read(joinPath(ctx.root, STATE_REL))
107 if (text === null) return null
108 const isUninitialized = isUninitializedState(text)
109 const lag = isUninitialized ? null : await stateLag(io, ctx.root)
110 let contextPercent: number | null = null
111 try {
112 const p = (await $.session.usage()).context.percent
113 contextPercent = typeof p === 'number' && Number.isFinite(p) ? p : null
114 } catch { contextPercent = null }
115 let turns = 1
116 try { turns = await $.session.turns() } catch { turns = 1 }
117 const view = viewOf(ctx.lang, {
118 health: isUninitialized ? 'unknown' : healthOf(text),
119 lag: lag === null ? 'none' : lag.kind,
120 behind: lag !== null && lag.kind === 'behind' ? lag.behind : null,
121 contextPercent,
122 isLegacyGaugeActive: await legacyStatuslineActive(io),
123 justOpened: turns === 0,
124 isUninitialized,
125 })
126 return { view, lang: ctx.lang, sessionId: await io.sessionId() }
127}
128
129/** Recompute the view, store it, and toast once per session when context runs hot. Never throws. */
130async function refresh($: EngineInterface): Promise<void> {
131 try {
132 const c = await compute($)
133 await update($, bandView, () => c === null ? null : c.view)
134 if (c === null || c.sessionId === '' || c.view.contextPercent === null || c.view.contextPercent < HOT) return
135 const held = await $.state.get(bandToastSession)
136 if (held.value === c.sessionId) return
137 // ifVersion: of two refreshes racing in one session only the winner of the write toasts.
138 const w = await $.state.set(bandToastSession, c.sessionId, { ifVersion: held.version })
139 if (!w.isSet) return
140 $.ui.toast(M(c.lang, {
141 en: 'flightwake: context is running hot — wrap up with /fw-record, then /clear and /fw-coldstart',
142 'zh-TW': 'flightwake:context 快滿了——先 /fw-record 收尾,再 /clear 與 /fw-coldstart 接手',
143 'zh-CN': 'flightwake:context 快满了——先 /fw-record 收尾,再 /clear 与 /fw-coldstart 接手',
144 ja: 'flightwake:コンテキストが逼迫しています。/fw-record で締めてから /clear と /fw-coldstart へ',
145 }), { timeoutMs: 8000 })
146 } catch {
147 try { await update($, bandView, () => null) } catch {}
148 }
149}
150
151function lagText(lang: Lang, v: FwBandView): string {
152 switch (v.lagKind) {
153 case 'dirty':
154 return M(lang, { en: 'STATE updating', 'zh-TW': 'STATE 更新中', 'zh-CN': 'STATE 更新中', ja: 'STATE 更新中' })
155 case 'error':
156 return M(lang, { en: 'STATE lag ?', 'zh-TW': 'STATE 落後量未知 ?', 'zh-CN': 'STATE 落后量未知 ?', ja: 'STATE の遅れ不明 ?' })
157 case 'behind': {
158 const n = v.behind ?? 0
159 return n > 0
160 ? M(lang, { en: `STATE ${n}c behind`, 'zh-TW': `STATE 落後 ${n}c`, 'zh-CN': `STATE 落后 ${n}c`, ja: `STATE ${n}c 遅れ` })
161 : M(lang, { en: 'STATE in sync', 'zh-TW': 'STATE 同步', 'zh-CN': 'STATE 同步', ja: 'STATE 同期済' })
162 }
163 default:
164 return '' // no-baseline / none: not measured, say nothing
165 }
166}
167
168export function registerBand(on: On): void {
169 on('session.start', {}, async ($, e, next) => {
170 const r = await next(e)
171 await refresh($)
172 return r
173 })
174
175 on('turn.complete', {}, async ($, e, next) => {
176 const r = await next(e)
177 await refresh($)
178 return r
179 })
180
181 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
182 const ran = await next(e)
183 try {
184 if (ran.deny === undefined && ran.isError === undefined) {
185 const op = (ran.result as { gitOperation?: { commit?: unknown; branch?: unknown } } | undefined)?.gitOperation
186 if (op && (op.commit || op.branch)) await refresh($)
187 }
188 } catch {}
189 return ran
190 })
191
192 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
193 try {
194 if (e.props.hasSurvey) return next(e)
195 const v = await read($, bandView)
196 if (!v || v.isQuiet) return next(e)
197 const lang = v.lang
198 const { Box, Text } = $.ui.resolve(e)
199 const color = v.health === 'green' ? 'green' : v.health === 'yellow' ? 'yellow' : v.health === 'red' ? 'red' : undefined
200 const lag = lagText(lang, v)
201 const pct = v.contextPercent
202 const row: unknown[] = [
203 h(Text, { bold: true, wrap: 'truncate' }, '✈ flightwake'),
204 h(Text, { color, wrap: 'truncate' }, ` · ●${v.health === 'unknown' ? '?' : v.health}`),
205 ]
206 if (lag) row.push(h(Text, { wrap: 'truncate' }, ` · ${lag}`))
207 // Always the percent when known (the band stands in for the gauge); the colour thresholds are unchanged
208 if (pct !== null) row.push(h(Text, { color: pct >= HOT ? 'red' : pct >= WARM ? 'yellow' : undefined, wrap: 'truncate' }, ` · ${Math.round(pct)}%`))
209 if (v.hint) row.push(h(Text, { dimColor: true, wrap: 'truncate-end' }, ` → ${v.hint}`))
210 const tree = h(Box, { width: e.props.bodyColumns, flexDirection: 'row', overflow: 'hidden' }, ...row)
211 return tree && typeof tree !== 'string' ? (tree as RenderElement) : next(e)
212 } catch {
213 return next(e)
214 }
215 })
216}
217hooks/features/recorder.ts 521 lines1/**
2 * F3 session flight log + /fw-log (docs/plans/mods.md, F3 as revised 2026-10-05).
3 *
4 * Observation only: the log holds what this session's tools did (files the agent edited, test/typecheck commands
5 * whose completion was observed, commits git reported) and nothing else. It never writes a record, never reads
6 * git-visible changes made by other programs, and is kept in $.state per session id (a /clear is a new id → fresh
7 * log; a module reload keeps it). No cross-session history.
8 *
9 * Conservative by design: a command is recorded only when it is recognised (known runner, a package script whose
10 * body was read, a command STATE.md declares as verification); the result is pass/fail only for a single plain
11 * command with a reliable completion and an exit code. Everything else that is recorded is 'unknown' with a reason.
12 */
13import { atom, update } from 'claude-code'
14import type { EngineInterface, On } from 'claude-code'
15
16import type { FwFlightLog, FwShellWrite, FwTestRun } from '../../types'
17import { M, STATE_REL, fwContext, packageScripts, readRel, relToRoot, tableCell as cell } from '../lib/core'
18import type { Io, Lang } from '../lib/core'
19import { parseCommand, shellWriteTargets } from '../lib/shell'
20import type { Segment } from '../lib/shell'
21import { declaredCommands, judge, type Judgment } from '../lib/testcmd'
22
23const flightLog = atom({ plugin: 'flightwake-mod', key: 'flightLog' } as const, null)
24
25const CAP_FILES = 500
26const CAP_TESTS = 200
27const CAP_COMMITS = 200
28const CAP_COMMAND = 300
29const CAP_SCRIPT = 200
30const LIST_LIMIT = 100
31
32// The Io closure over this file's `$` (IO_OF_TEMPLATE in hooks/lib/core.ts; `$` may not cross an import).
33function ioOf($: EngineInterface): Io {
34 return {
35 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
36 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
37 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
38 read: async (p) => {
39 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
40 },
41 git: async (args, cwd) => {
42 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
43 },
44 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
45 }
46}
47
48// ---------------------------------------------------------------------------------------------------------------
49// Redaction: applied to every command and script text before it is stored (never to what is matched against).
50// ---------------------------------------------------------------------------------------------------------------
51
52const SECRET_WORDS = 'token|secret|password|passwd|pwd|apikey|api_key|auth|credential|key'
53const VALUE = `"[^"]*"|'[^']*'|\\S+`
54const RE_URL_CREDS = /(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]*@/gi
55const RE_BEARER = /\bBearer\s+[^\s"']+/gi
56const RE_KEY_VALUE = new RegExp(`([A-Za-z0-9_.-]*(?:${SECRET_WORDS})[A-Za-z0-9_.-]*=)(?:${VALUE})`, 'gi')
57const RE_FLAG_VALUE = new RegExp(`(--?[A-Za-z0-9_.-]*(?:${SECRET_WORDS})[A-Za-z0-9_.-]*\\s+)(?!-)(?:${VALUE})`, 'gi')
58const RE_LONG_RUN = /[A-Za-z0-9+/=_-]{32,}/g
59
60export function redact(text: string, cap = CAP_COMMAND): string {
61 const out = text
62 .replace(RE_URL_CREDS, '$1***@')
63 .replace(RE_BEARER, 'Bearer ***')
64 .replace(RE_KEY_VALUE, '$1***')
65 .replace(RE_FLAG_VALUE, '$1***')
66 // A long opaque run with a digit is a likely token; paths (leading / . ~) and plain words are kept, or the
67 // evidence itself (e.g. an absolute test path) would be erased.
68 .replace(RE_LONG_RUN, (m) => (/^[./~]/.test(m) || !/\d/.test(m) || !/[A-Za-z]/.test(m) ? m : '***'))
69 return out.length > cap ? `${out.slice(0, cap - 1)}…` : out
70}
71
72// ---------------------------------------------------------------------------------------------------------------
73// Judging a Bash call before it runs: hooks/lib/testcmd.ts (positive proof; pure). Here: the cd prefix and the cwd.
74// ---------------------------------------------------------------------------------------------------------------
75
76type Plan = {
77 cwd: string
78 /** Absolute cwd, for git. */
79 cwdAbs: string
80 /** Leading `cd <dir> &&` segments were stripped: exit 0 still proves the run, a non-zero exit may be the cd's. */
81 hasCdPrefix: boolean
82 judgment: Judgment
83}
84
85const isCdPrefix = (s: Segment): boolean =>
86 s.tokens[0] === 'cd' && s.op === '&&' && s.env.length === 0 && (s.tokens.length === 2 || (s.tokens.length === 3 && s.tokens[1] === '--'))
87
88async function planBash(io: Io, root: string, cwd0: string, command: string): Promise<Plan | null> {
89 const parsed = parseCommand(command.trim())
90 let segs = parsed.segments
91 let cwdAbs = cwd0 || root
92 let hasCdPrefix = false
93 // Leading `cd <dir> &&` segments only move the cwd.
94 while (segs.length > 1 && isCdPrefix(segs[0] as Segment)) {
95 const dir = (segs[0] as Segment).tokens.at(-1) as string // `cd dir` or `cd -- dir`
96 if (dir === '-' || dir.startsWith('~') || dir.includes('$')) return null // not resolvable without guessing
97 cwdAbs = dir.startsWith('/') ? dir : `${cwdAbs}/${dir}`
98 segs = segs.slice(1)
99 hasCdPrefix = true
100 }
101 if (segs.length === 0) return null
102 const rel = relToRoot(root, cwdAbs)
103 const cwdRel = rel === null ? cwdAbs : rel
104 const stateText = await readRel(io, root, STATE_REL)
105 const scripts = rel === null ? null : await packageScripts(io, root, cwdRel)
106 const judgment = judge(segs, parsed.isComplex, { scripts, declared: stateText === null ? [] : declaredCommands(stateText) })
107 if (judgment === null) return null
108 return { cwd: cwdRel === '' ? '.' : cwdRel, cwdAbs, hasCdPrefix, judgment }
109}
110
111// ---------------------------------------------------------------------------------------------------------------
112// Reading the outcome
113// ---------------------------------------------------------------------------------------------------------------
114
115type Outcome = { result: FwTestRun['result']; exitCode: number | null; reason?: string }
116
117type BashResultFields = { interrupted?: boolean; backgroundTaskId?: string; timedOutAfterMs?: number; gitOperation?: { commit?: { sha: string; kind: string } } }
118
119/** What the tool observably did: its exit code when it completed in the foreground, else why there is none. */
120export function observe(res: { isError?: boolean; result?: unknown; text?: string }, isBackground: boolean): { exitCode: number | null; reason?: string } {
121 if (res.isError === true) {
122 const text = typeof res.text === 'string' ? res.text : typeof res.result === 'string' ? res.result : ''
123 const m = /^Exit code (\d+)/.exec(text)
124 return m ? { exitCode: Number(m[1]) } : { exitCode: null, reason: 'no-exit-code' }
125 }
126 const r = res.result
127 if (r === undefined || r === null || typeof r !== 'object') return { exitCode: null, reason: 'no-result' }
128 const b = r as BashResultFields
129 if (isBackground || b.backgroundTaskId) return { exitCode: null, reason: 'background' }
130 if (b.interrupted === true) return { exitCode: null, reason: 'interrupted' }
131 if (b.timedOutAfterMs !== undefined) return { exitCode: null, reason: 'timeout' }
132 return { exitCode: 0 }
133}
134
135/**
136 * pass/fail only for a proven judgment with an observed exit code; everything else is unknown with its reason, and
137 * keeps whatever exit code was observed so the reader can weigh it.
138 */
139export function outcomeOf(j: Judgment, seen: { exitCode: number | null; reason?: string }, hasCdPrefix: boolean): Outcome {
140 if (seen.exitCode === null) return { result: 'unknown', exitCode: null, reason: seen.reason ?? 'no-exit-code' }
141 if (!j.isProven) return { result: 'unknown', exitCode: seen.exitCode, reason: j.reason ?? 'unproven' }
142 if (seen.exitCode === 0) return { result: 'pass', exitCode: 0 }
143 if (hasCdPrefix) return { result: 'unknown', exitCode: seen.exitCode, reason: 'cd-prefix' }
144 return { result: 'fail', exitCode: seen.exitCode }
145}
146
147// ---------------------------------------------------------------------------------------------------------------
148// The log
149// ---------------------------------------------------------------------------------------------------------------
150
151const freshLog = (sessionId: string, now: number): FwFlightLog => ({ sessionId, startedAt: now, files: [], shellFiles: [], tests: [], commits: [], dropped: 0 })
152
153/** Applies `change` to this session's log (a log of another session id is replaced: that is the /clear behaviour). */
154async function record($: EngineInterface, io: Io, change: (log: FwFlightLog) => void): Promise<void> {
155 // Same install test as every feature: no .flightwake/STATE.md under the root → this folder is not ours, keep nothing.
156 if ((await fwContext(io)) === null) return
157 const sid = await io.sessionId()
158 const now = await $.clock.now()
159 await update($, flightLog, (cur) => {
160 const base = cur !== null && cur.sessionId === sid ? cur : freshLog(sid, now)
161 const log: FwFlightLog = { ...base, files: [...base.files], shellFiles: [...(base.shellFiles ?? [])], tests: [...base.tests], commits: [...base.commits] }
162 change(log)
163 return log
164 })
165}
166
167function touchFile(log: FwFlightLog, path: string, tool: string, at: number, agentId: string | undefined): void {
168 const i = log.files.findIndex((f) => f.path === path)
169 if (i >= 0) log.files.splice(i, 1)
170 else if (log.files.length >= CAP_FILES) {
171 log.dropped += 1
172 return
173 }
174 log.files.push(agentId === undefined ? { path, tool, at } : { path, tool, at, agentId })
175}
176
177function touchShellFile(log: FwFlightLog, w: FwShellWrite): void {
178 const list = log.shellFiles ?? (log.shellFiles = [])
179 const i = list.findIndex((f) => f.path === w.path)
180 if (i >= 0) list.splice(i, 1)
181 else if (list.length >= CAP_FILES) {
182 log.dropped += 1
183 return
184 }
185 list.push(w)
186}
187
188/**
189 * Candidate files a Bash command wrote, from its words (lib/shell shellWriteTargets), as repo-relative paths. A record
190 * would rather miss than misrecord (unlike F4's hints, which would rather over-warn): a relative word is resolved only
191 * while the cwd is certain (start, or a leading `cd X &&` chain), and paths outside the repo are dropped. The caller
192 * then keeps only what git confirms as changed.
193 */
194function shellCandidates(root: string, cwd0: string, command: string): Array<{ path: string; via: string }> {
195 const out: Array<{ path: string; via: string }> = []
196 for (const w of shellWriteTargets(command)) {
197 let abs: string
198 if (w.path.startsWith('/')) abs = w.path
199 else if (w.dir === null) continue // the shell may be anywhere by now: don't guess
200 else abs = w.dir.startsWith('/') ? `${w.dir}/${w.path}` : `${cwd0 || root}/${w.dir ? `${w.dir}/` : ''}${w.path}`
201 const rel = relToRoot(root, abs)
202 if (rel !== null && rel !== '' && !rel.startsWith('.git/') && !out.some((o) => o.path === rel)) out.push({ path: rel, via: w.via })
203 }
204 return out
205}
206
207/**
208 * The candidates git reports as changed after the command — modified, added, untracked or deleted (a removal only
209 * shows for a tracked file, which is the point). Read-only (--no-optional-locks via io.git; --literal-pathspecs so a
210 * path is never a pattern). Not a repo, git failing, or a candidate git does not list → left out.
211 */
212async function confirmedByGit(io: Io, root: string, candidates: Array<{ path: string; via: string }>): Promise<Array<{ path: string; via: string }>> {
213 if (candidates.length === 0) return []
214 const out = await io.git(['--literal-pathspecs', 'status', '--porcelain', '-z', '--untracked-files=all', '--', ...candidates.map((c) => c.path)], root)
215 if (out === null) return []
216 const changed = new Set<string>()
217 const parts = out.split('\0')
218 for (let i = 0; i < parts.length; i++) {
219 // `XY path`; io.git trims the output, so the first entry may have lost the leading space of its status
220 const m = /^([ MADRCUT?!]{1,2}) (.+)$/.exec(parts[i] as string)
221 if (!m) continue
222 changed.add(m[2] as string)
223 if (/[RC]/.test((m[1] as string)[0] as string)) { const from = parts[++i]; if (from) changed.add(from) } // rename: the source follows
224 }
225 return candidates.filter((c) => changed.has(c.path))
226}
227
228/** The one-per-session note for a test run that was chained with other commands (its own exit code is not visible). */
229const chainHint = (lang: Lang): string => M(lang, {
230 en: 'flightwake: this test command ran chained with other commands, so only the whole chain\'s exit code was visible and the run cannot count as passing evidence. When you need evidence (for fw-record\'s tests:), run the test command on its own once.',
231 'zh-TW': 'flightwake:這次的測試是和其他指令串在一起跑的,只看得到整串的退出碼,無法當成通過的證據。需要留證據(fw-record 的 tests:)時,請把測試指令單獨執行一次。',
232 'zh-CN': 'flightwake:这次的测试是和其他命令串在一起跑的,只看得到整串的退出码,无法当成通过的证据。需要留证据(fw-record 的 tests:)时,请把测试命令单独执行一次。',
233 ja: 'flightwake:このテストは他のコマンドとつなげて実行されたため、見えるのはつなげた全体の終了コードだけで、成功の証拠になりません。証拠が必要なとき(fw-record の tests:)は、テストコマンドを単独で一度実行してください。',
234})
235
236// ---------------------------------------------------------------------------------------------------------------
237// /fw-log
238// ---------------------------------------------------------------------------------------------------------------
239
240/** Local UTC offset in minutes (east positive), or null when unknown. */
241export type TzOffset = number | null
242
243/** `+0800` → 480; anything else → null. */
244export function parseOffset(s: string): TzOffset {
245 const m = /^([+-])(\d{2})(\d{2})$/.exec(s.trim())
246 return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : null
247}
248
249const stamp = (ms: number): string => new Date(ms).toISOString().replace('T', ' ').slice(0, 19)
250/** Local time with its offset, then UTC (`2026-10-05 03:32:10 +0800 (19:32:10 UTC)`); UTC only when the offset is unknown. */
251export function when(ms: number, tz: TzOffset = null): string {
252 if (tz === null) return `${stamp(ms)} UTC`
253 const sign = tz < 0 ? '-' : '+'
254 const a = Math.abs(tz)
255 const off = `${sign}${String(Math.floor(a / 60)).padStart(2, '0')}${String(a % 60).padStart(2, '0')}`
256 return `${stamp(ms + tz * 60_000)} ${off} (${stamp(ms).slice(11)} UTC)`
257}
258const code = (s: string): string => (s.includes('`') ? cell(s) : `\`${cell(s)}\``)
259
260export function renderLog(lang: Lang, log: FwFlightLog | null, tz: TzOffset = null): string {
261 const lines: string[] = []
262 lines.push(`## ${M(lang, { en: 'flightwake session log', 'zh-TW': 'flightwake 本 session 記錄', 'zh-CN': 'flightwake 本 session 记录', ja: 'flightwake セッション記録' })}`, '')
263 const isEmpty = log === null || (log.files.length === 0 && (log.shellFiles ?? []).length === 0 && log.tests.length === 0 && log.commits.length === 0)
264 if (isEmpty || log === null) {
265 lines.push(M(lang, {
266 en: 'Nothing observed yet in this session.',
267 'zh-TW': '本 session 尚未觀測到任何檔案變更、測試或 commit。',
268 'zh-CN': '本 session 尚未观测到任何文件变更、测试或 commit。',
269 ja: 'このセッションではまだ何も観測されていません。',
270 }), '')
271 } else {
272 lines.push(`### ${M(lang, { en: 'Files changed by the agent', 'zh-TW': 'agent 改過的檔案', 'zh-CN': 'agent 改过的文件', ja: 'エージェントが変更したファイル' })} (${log.files.length})`, '')
273 if (log.files.length === 0) lines.push(M(lang, { en: '_none_', 'zh-TW': '_無_', 'zh-CN': '_无_', ja: '_なし_' }))
274 for (const f of log.files.slice(0, LIST_LIMIT)) lines.push(`- ${code(f.path)} (${f.tool}${f.agentId ? `, subagent ${f.agentId}` : ''})`)
275 if (log.files.length > LIST_LIMIT) lines.push(M(lang, { en: `- … and ${log.files.length - LIST_LIMIT} more`, 'zh-TW': `- …另有 ${log.files.length - LIST_LIMIT} 個`, 'zh-CN': `- …另有 ${log.files.length - LIST_LIMIT} 个`, ja: `- …ほか ${log.files.length - LIST_LIMIT} 件` }))
276 lines.push('')
277
278 const shell = log.shellFiles ?? []
279 if (shell.length > 0) {
280 lines.push(`### ${M(lang, {
281 en: 'Files possibly changed through shell commands — inferred from the commands, may be incomplete',
282 'zh-TW': '可能經由 shell 指令改動的檔案——由指令推斷,可能不完整',
283 'zh-CN': '可能经由 shell 命令改动的文件——由命令推断,可能不完整',
284 ja: 'シェルコマンドで変更された可能性のあるファイル——コマンドから推定、不完全な場合あり',
285 })} (${shell.length})`, '')
286 for (const f of shell.slice(0, LIST_LIMIT)) lines.push(`- ${code(f.path)} (${f.via}${f.agentId ? `, subagent ${f.agentId}` : ''})`)
287 if (shell.length > LIST_LIMIT) lines.push(M(lang, { en: `- … and ${shell.length - LIST_LIMIT} more`, 'zh-TW': `- …另有 ${shell.length - LIST_LIMIT} 個`, 'zh-CN': `- …另有 ${shell.length - LIST_LIMIT} 个`, ja: `- …ほか ${shell.length - LIST_LIMIT} 件` }))
288 lines.push('', M(lang, {
289 en: 'Read from redirections and cp / mv / rm / tee / sed -i in the commands the agent ran, and listed only where the path could be confirmed (inside the repo, and reported as changed by git afterwards) — so some changes may be missing. Writes made any other way (scripts, other programs, git) are not listed.',
290 'zh-TW': '依 agent 執行的指令中的重導向與 cp / mv / rm / tee / sed -i 推斷,且只列出能確認的路徑(在 repo 內、事後 git 也顯示有變更)——所以可能漏記。以其他方式寫入的(腳本、其他程式、git)不會列出。',
291 'zh-CN': '依 agent 执行的命令中的重定向与 cp / mv / rm / tee / sed -i 推断,且只列出能确认的路径(在 repo 内、事后 git 也显示有变更)——所以可能漏记。以其他方式写入的(脚本、其他程序、git)不会列出。',
292 ja: 'エージェントが実行したコマンドのリダイレクトと cp / mv / rm / tee / sed -i から推定し、確認できたパスだけを載せる(repo 内で、実行後に git が変更ありと示すもの)——そのため漏れがありうる。それ以外の方法(スクリプト、他のプログラム、git)による書き込みは載りません。',
293 }), '')
294 }
295
296 lines.push(`### ${M(lang, { en: 'Test / typecheck runs', 'zh-TW': '測試 / typecheck 執行', 'zh-CN': '测试 / typecheck 执行', ja: 'テスト / typecheck 実行' })} (${log.tests.length})`, '')
297 if (log.tests.length === 0) lines.push(M(lang, { en: '_none observed_', 'zh-TW': '_未觀測到_', 'zh-CN': '_未观测到_', ja: '_観測なし_' }))
298 else {
299 lines.push(`| ${M(lang, { en: 'command', 'zh-TW': '指令', 'zh-CN': '指令', ja: 'コマンド' })} | ${M(lang, { en: 'kind', 'zh-TW': '類型', 'zh-CN': '类型', ja: '種別' })} | ${M(lang, { en: 'result', 'zh-TW': '結果', 'zh-CN': '结果', ja: '結果' })} | ${M(lang, { en: 'exit code', 'zh-TW': '退出碼', 'zh-CN': '退出码', ja: '終了コード' })} | ${M(lang, { en: 'finished', 'zh-TW': '完成時間', 'zh-CN': '完成时间', ja: '終了時刻' })} | ${M(lang, { en: 'revision', 'zh-TW': '版本', 'zh-CN': '版本', ja: 'リビジョン' })} | cwd | ${M(lang, { en: 'reason', 'zh-TW': '原因', 'zh-CN': '原因', ja: '理由' })} |`)
300 lines.push('|---|---|---|---|---|---|---|---|')
301 for (const r of log.tests) {
302 const result = r.result === 'pass'
303 ? M(lang, { en: 'pass', 'zh-TW': '通過', 'zh-CN': '通过', ja: '成功' })
304 : r.result === 'fail'
305 ? M(lang, { en: 'fail', 'zh-TW': '失敗', 'zh-CN': '失败', ja: '失敗' })
306 : M(lang, { en: 'unknown', 'zh-TW': '未知', 'zh-CN': '未知', ja: '不明' })
307 const rev = r.revision === null ? '?' : `${r.revision.slice(0, 7)}${r.isDirty === true ? '*' : r.isDirty === null ? '?' : ''}`
308 const kind = r.kind === 'package-script' && r.script ? `package-script (${cell(r.script)})` : r.kind
309 lines.push(`| ${code(r.command)} | ${kind} | ${result} | ${r.exitCode === null ? '-' : r.exitCode} | ${when(r.finishedAt, tz)} | ${rev} | ${code(r.cwd)} | ${r.reason ?? ''}${r.agentId ? `${r.reason ? ' ' : ''}subagent ${r.agentId}` : ''} |`)
310 }
311 lines.push('', M(lang, {
312 en: '`*` = the working tree had uncommitted changes when the command started; `?` = could not be read.',
313 'zh-TW': '`*` = 指令開始時工作區有未 commit 的變更;`?` = 讀不到。',
314 'zh-CN': '`*` = 指令开始时工作区有未 commit 的变更;`?` = 读不到。',
315 ja: '`*` = コマンド開始時に未コミットの変更あり、`?` = 取得不可。',
316 }))
317 }
318 lines.push('')
319
320 lines.push(`### Commits (${log.commits.length})`, '')
321 if (log.commits.length === 0) lines.push(M(lang, { en: '_none_', 'zh-TW': '_無_', 'zh-CN': '_无_', ja: '_なし_' }))
322 for (const c of log.commits) lines.push(`- ${code(c.sha.slice(0, 7))} ${c.kind} ${when(c.at, tz)}${c.agentId ? ` (subagent ${c.agentId})` : ''}`)
323 lines.push('')
324
325 if (log.dropped > 0) {
326 lines.push(M(lang, {
327 en: `Note: ${log.dropped} older or overflow entries were dropped by the size caps.`,
328 'zh-TW': `注意:${log.dropped} 筆因容量上限未收錄。`,
329 'zh-CN': `注意:${log.dropped} 条因容量上限未收录。`,
330 ja: `注意: 上限を超えた ${log.dropped} 件は記録されていません。`,
331 }), '')
332 }
333 }
334 lines.push('---', M(lang, {
335 en: 'Observed by flightwake-mod in this session only: changes made by other programs and runs outside this session are not included. Use the rows verbatim as fw-record\'s `tests:` evidence and change list; `unknown` rows are not evidence of passing.',
336 'zh-TW': '由 flightwake-mod 只在本 session 內觀測:其他程式造成的變更、本 session 以外的執行都不在內。可把這些列原樣作為 fw-record 的 `tests:` 證據與變更清單;`unknown`(未知)的列不是通過的證據。',
337 'zh-CN': '由 flightwake-mod 只在本 session 内观测:其他程序造成的变更、本 session 以外的执行都不在内。可把这些行原样作为 fw-record 的 `tests:` 证据与变更清单;`unknown`(未知)的行不是通过的证据。',
338 ja: 'flightwake-mod がこのセッション内でのみ観測したものです。他のプログラムによる変更やセッション外の実行は含まれません。各行は fw-record の `tests:` 証拠と変更一覧にそのまま使えます。`unknown` の行は成功の証拠ではありません。',
339 }), '', M(lang, {
340 en: '`pass` means: a directly called runner ran recognisably and returned 0. It cannot see config files or the outside environment that may keep tests from running (e.g. addopts in pytest.ini, a skip in a build profile), and it is not a guarantee that the tests themselves are meaningful.',
341 'zh-TW': '`pass`(通過)的意思是:直接呼叫的 runner 以可辨識的方式執行並回傳 0。它看不到設定檔或外部環境裡會讓測試不執行的設定(例如 pytest.ini 的 addopts、建置 profile 裡的略過設定),也不保證測試內容本身有效。',
342 'zh-CN': '`pass`(通过)的意思是:直接调用的 runner 以可辨识的方式执行并返回 0。它看不到配置文件或外部环境里会让测试不执行的设置(例如 pytest.ini 的 addopts、构建 profile 里的跳过设置),也不保证测试内容本身有效。',
343 ja: '`pass` の意味:直接呼び出した runner が判別できる形で実行され 0 を返したこと。設定ファイルや外部環境にあるテストを実行させない設定(pytest.ini の addopts、ビルド profile のスキップなど)は見えず、テスト内容そのものが有効である保証でもありません。',
344 }))
345 return lines.join('\n')
346}
347
348// ---------------------------------------------------------------------------------------------------------------
349// Registration
350// ---------------------------------------------------------------------------------------------------------------
351
352// Declares /fw-log: at session.start, and once more lazily on first use (a hot reload may not re-fire session.start).
353// Top-level so that `$` is passed only to a function declared at the top of this file.
354let isCommandRegistered = false
355async function ensureCommand($: EngineInterface): Promise<void> {
356 if (isCommandRegistered) return
357 isCommandRegistered = true
358 try {
359 const io = ioOf($)
360 const ctx = await fwContext(io)
361 if (ctx === null) {
362 isCommandRegistered = false // not installed here: no command (a later record in an installed root registers it)
363 return
364 }
365 const lang = ctx.lang
366 await $.command.register({
367 name: 'fw-log',
368 description: M(lang, {
369 en: 'Print this session\'s observed files, test runs and commits (flightwake-mod)',
370 'zh-TW': '印出本 session 觀測到的檔案變更、測試結果與 commit(flightwake-mod)',
371 'zh-CN': '打印本 session 观测到的文件变更、测试结果与 commit(flightwake-mod)',
372 ja: 'このセッションで観測したファイル変更・テスト結果・commit を表示(flightwake-mod)',
373 }),
374 })
375 } catch {
376 isCommandRegistered = false
377 }
378}
379
380
381export function registerRecorder(on: On): void {
382 on('session.start', {}, async ($, e, next) => {
383 await ensureCommand($)
384 return next(e)
385 })
386
387 on('command.run', { command: 'fw-log' }, async ($, e, next) => {
388 try {
389 const io = ioOf($)
390 const ctx = await fwContext(io)
391 if (ctx === null) return next(e) // not installed here: as if the command did not exist
392 const sid = await io.sessionId()
393 const cur = (await $.state.get({ plugin: 'flightwake-mod', key: 'flightLog' } as const)).value ?? null
394 const log = cur !== null && cur.sessionId === sid ? cur : null
395 // Local offset from the OS (read-only, the person's own clock settings); unknown → UTC only
396 let tz: TzOffset = null
397 try {
398 const r = await $.process.run(['date', '+%z'], { timeoutMs: 2000 })
399 if (r.exitCode === 0) tz = parseOffset(r.stdout)
400 } catch {}
401 return { text: renderLog(ctx.lang, log, tz) }
402 } catch {
403 return next(e)
404 }
405 })
406
407 on('tool.call', {}, async ($, e, next) => {
408 const tool = e.tool
409 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
410 const res = await next(e)
411 try {
412 if (res.deny === undefined && res.isError !== true) {
413 const io = ioOf($)
414 const root = await io.root()
415 const abs = tool === 'NotebookEdit' ? (e as { notebook_path?: string }).notebook_path : (e as { file_path?: string }).file_path
416 if (typeof abs === 'string' && abs) {
417 const path = root ? (relToRoot(root, abs) ?? abs) : abs
418 const at = await $.clock.now()
419 const agentId = e.agentId
420 await ensureCommand($)
421 await record($, io, (log) => touchFile(log, path, tool, at, agentId))
422 }
423 }
424 } catch {}
425 return res
426 }
427 if (tool !== 'Bash') return next(e)
428
429 // Bash: plan before it runs (revision / dirtiness must be those of the start), observe after.
430 const command = (e as { command?: string }).command
431 const isBackground = (e as { run_in_background?: boolean }).run_in_background === true
432 let plan: Plan | null = null
433 let revision: string | null = null
434 let isDirty: boolean | null = null
435 let startedAt = 0
436 try {
437 if (typeof command === 'string' && command.trim() && (await fwContext(ioOf($))) !== null) {
438 const io = ioOf($)
439 const root = await io.root()
440 let cwd0 = root
441 try { cwd0 = (await $.session.cwd()).replace(/\/+$/, '') || root } catch {}
442 plan = await planBash(io, root, cwd0, command)
443 if (plan !== null) {
444 revision = await io.git(['rev-parse', 'HEAD'], plan.cwdAbs)
445 const st = await io.git(['status', '--porcelain'], plan.cwdAbs)
446 isDirty = st === null ? null : st !== ''
447 startedAt = await $.clock.now()
448 }
449 }
450 } catch {
451 plan = null
452 }
453
454 const res = await next(e)
455 let hint: string | null = null
456
457 try {
458 if (res.deny === undefined) {
459 const io = ioOf($)
460 const finishedAt = await $.clock.now()
461 // Files the command wrote, as far as its words say (listed apart from tool edits; F4 reads the same words)
462 let shellWrites: Array<{ path: string; via: string }> = []
463 let lang: Lang = 'en'
464 if (typeof command === 'string' && command.trim()) {
465 const ctx = await fwContext(io)
466 if (ctx !== null) {
467 lang = ctx.lang
468 let cwd0 = ctx.root
469 try { cwd0 = (await $.session.cwd()).replace(/\/+$/, '') || ctx.root } catch {}
470 shellWrites = await confirmedByGit(io, ctx.root, shellCandidates(ctx.root, cwd0, command))
471 }
472 }
473 const agentId = e.agentId
474 const commit = res.isError === true ? undefined : (res.result as BashResultFields | undefined)?.gitOperation?.commit
475 const runs: FwTestRun[] = []
476 if (plan !== null && typeof command === 'string') {
477 const j = plan.judgment
478 const o = outcomeOf(j, observe(res, isBackground), plan.hasCdPrefix)
479 const run: FwTestRun = {
480 command: redact(command.trim()),
481 kind: j.kind,
482 cwd: plan.cwd,
483 startedAt,
484 finishedAt,
485 revision,
486 isDirty,
487 result: o.result,
488 exitCode: o.exitCode,
489 }
490 if (j.script !== undefined) run.script = redact(j.script, CAP_SCRIPT)
491 if (o.reason !== undefined) run.reason = o.reason
492 if (agentId !== undefined) run.agentId = agentId
493 runs.push(run)
494 }
495 const isChained = runs.some((r) => r.reason === 'compound')
496 if (runs.length > 0 || shellWrites.length > 0 || (commit !== undefined && commit.sha)) {
497 await ensureCommand($)
498 await record($, io, (log) => {
499 for (const w of shellWrites) touchShellFile(log, agentId === undefined ? { ...w, at: finishedAt } : { ...w, at: finishedAt, agentId })
500 if (isChained && log.isChainHintShown !== true) {
501 log.isChainHintShown = true
502 hint = chainHint(lang)
503 }
504 for (const r of runs) {
505 if (log.tests.length >= CAP_TESTS) log.dropped += 1
506 else log.tests.push(r)
507 }
508 if (commit !== undefined && commit.sha && !log.commits.some((c) => c.sha === commit.sha && c.kind === commit.kind)) {
509 if (log.commits.length >= CAP_COMMITS) log.dropped += 1
510 else log.commits.push(agentId === undefined ? { sha: commit.sha, kind: commit.kind, at: finishedAt } : { sha: commit.sha, kind: commit.kind, at: finishedAt, agentId })
511 }
512 })
513 }
514 }
515 } catch {}
516 // The chained-run note rides on this tool result (as F4's hints do): it states a fact and what to do, blocks nothing
517 if (hint === null || res.deny !== undefined) return res
518 return { ...res, context: [...(res.context ?? []), hint] }
519 })
520}
521hooks/features/tripwire.ts 239 lines1/**
2 * F4 TRAPS tripwires: when an Edit/Write/NotebookEdit or Bash call matches an active TRAPS entry's `paths` or
3 * `commands`, a compact hint is appended to the tool result's `context` (what the model reads after the result).
4 * It NEVER blocks: a denied call passes through untouched, and every failure falls through to the plain result.
5 * Contract: docs/plans/mods.md (revised) and the header of hooks/lib/core.ts. Read scope: .flightwake/TRAPS.md only.
6 */
7import { atom, update } from 'claude-code'
8import type { EngineInterface, On } from 'claude-code'
9
10import { M, fwContext, readRel, relToRoot, TRAPS_REL } from '../lib/core'
11import type { Io, Lang } from '../lib/core'
12import { matchAny } from '../lib/glob'
13import { parseCommand, startsWithTokens } from '../lib/shell'
14import { hasMatchers, isActive, parseTraps } from '../lib/traps'
15import type { TrapEntry } from '../lib/traps'
16
17const hinted = atom({ plugin: 'flightwake-mod', key: 'trapsHinted' } as const, null)
18
19const MAX_ENTRIES = 5
20const MAX_LINE = 300
21
22function ioOf($: EngineInterface): Io {
23 return {
24 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
25 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
26 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
27 read: async (p) => {
28 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
29 },
30 git: async (args, cwd) => {
31 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
32 },
33 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
34 }
35}
36
37type Hit = { entry: TrapEntry; via: string }
38
39const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null)
40
41/** A bash word that plausibly names a repo file: has `/` or `.`, isn't an option, a URL or a home shorthand. */
42function looksLikePath(t: string): boolean {
43 if (t.startsWith('-') || t.startsWith('~') || t.includes('://') || t.includes('=')) return false
44 return t.includes('/') || t.includes('.')
45}
46
47function matchFile(entries: TrapEntry[], root: string, filePath: string): Hit[] {
48 const rel = relToRoot(root, filePath)
49 if (rel === null || rel === '') return []
50 const out: Hit[] = []
51 for (const entry of entries) if (entry.paths.length && matchAny(entry.paths, rel) !== null) out.push({ entry, via: rel })
52 return out
53}
54
55/** At most this many possible cwds are tracked; past it, matching degrades to glob tails (no cwd at all). */
56const MAX_CWDS = 16
57
58/**
59 * Coarse match used once the cwd is no longer tracked: does `glob` match the path word itself or any tail of the
60 * glob match it (`pkg/src/**` → `src/**` → `**`)? Over-hints by design — F4 only hints, once per entry per session.
61 */
62function tailMatch(globs: readonly string[], word: string): boolean {
63 const w = word.replace(/^\.\//, '')
64 for (const g of globs) {
65 const segs = g.replace(/^\.?\//, '').split('/')
66 for (let k = 0; k < segs.length; k++) {
67 const tail = segs.slice(k).join('/')
68 if (tail && tail !== '**' && matchAny([tail], w) !== null) return true
69 }
70 }
71 return false
72}
73
74/** The file a redirection names (`>out`, `2>>log`, `&>f`, `<in`), or the word itself. '' for a bare operator. */
75const redirectTarget = (tok: string): string => tok.replace(/^(?:\d*|&)?[<>]>?/, '')
76
77/**
78 * Bash: command prefixes per segment, and path-like words resolved against every directory the shell could be in.
79 * F4 only hints and hints once per entry per session, so when the cwd is uncertain it over-hints rather than miss:
80 * - only a plain chain `cd X && …` is a certain move (the set of cwds is replaced);
81 * - a `cd` followed by `||`, `&`, `|`, `;` or a newline, or after any such operator, may or may not have moved this
82 * shell: the moved directories are ADDED to the set;
83 * - `( … )` subshells are walked: inside, cds apply as above; after the `)`, the set from before the `(` is back;
84 * - a `cd` that can't be resolved without guessing (`cd -`, `~`, `$VAR`, bare `cd`) leaves the set as it is;
85 * - the set is bounded (MAX_CWDS): past it, matching degrades to glob tails (tailMatch) for the rest, so
86 * the hint never costs more than one pass over the words — a long chain of uncertain cds can't blow it up.
87 */
88function matchBash(entries: TrapEntry[], root: string, cwd0: string, command: string): Hit[] {
89 const parsed = parseCommand(command)
90 const out = new Map<TrapEntry, string>()
91 let cwds: Set<string> | null = new Set<string>([cwd0]) // null = degraded: cwd no longer tracked
92 let isCertain = true
93 const stack: Array<{ cwds: Set<string> | null; isCertain: boolean }> = []
94 for (const seg of parsed.segments) {
95 if (seg.group === 'open') {
96 stack.push({ cwds: cwds === null ? null : new Set(cwds), isCertain })
97 continue
98 }
99 if (seg.group === 'close') {
100 const saved = stack.pop()
101 if (saved) ({ cwds, isCertain } = saved)
102 if (seg.op && seg.op !== '&&') isCertain = false
103 continue
104 }
105 for (const entry of entries) {
106 if (out.has(entry)) continue
107 const prefix = entry.commands.find((p) => startsWithTokens(seg.tokens, p))
108 if (prefix !== undefined) {
109 out.set(entry, prefix.trim().split(/\s+/).join(' '))
110 continue
111 }
112 if (!entry.paths.length) continue
113 search: for (const tok of seg.tokens) {
114 const word = redirectTarget(tok)
115 if (!word || !looksLikePath(word)) continue
116 if (word.startsWith('/')) {
117 const rel = relToRoot(root, word)
118 if (rel !== null && rel !== '' && matchAny(entry.paths, rel) !== null) { out.set(entry, rel); break search }
119 continue
120 }
121 if (cwds === null) {
122 if (tailMatch(entry.paths, word)) { out.set(entry, word); break search }
123 continue
124 }
125 for (const cwd of cwds) {
126 const rel = relToRoot(root, `${cwd}/${word}`)
127 if (rel !== null && rel !== '' && matchAny(entry.paths, rel) !== null) { out.set(entry, rel); break search }
128 }
129 }
130 }
131 if (seg.tokens[0] === 'cd' && cwds !== null) {
132 const args = seg.tokens[1] === '--' ? seg.tokens.slice(2) : seg.tokens.slice(1)
133 const dir = args[0]
134 const isResolvable = args.length === 1 && dir !== undefined && dir !== '-' && !dir.startsWith('~') && !dir.includes('$')
135 if (isResolvable) {
136 const moved = [...cwds].map((c) => (dir.startsWith('/') ? dir : `${c}/${dir}`))
137 const next: Set<string> = isCertain && seg.op === '&&' ? new Set(moved) : new Set([...cwds, ...moved])
138 cwds = next.size > MAX_CWDS ? null : next
139 }
140 }
141 if (seg.op && seg.op !== '&&') isCertain = false
142 }
143 return [...out].map(([entry, via]) => ({ entry, via }))
144}
145
146const clip = (s: string): string => (s.length > MAX_LINE ? `${s.slice(0, MAX_LINE)}…` : s)
147
148function render(lang: Lang, shown: Hit[], more: number): string {
149 const lines: string[] = []
150 let isLead = false
151 for (const { entry, via } of shown) {
152 if (entry.confidence !== 'confirmed') isLead = true
153 lines.push(
154 M(lang, {
155 en: `flightwake TRAPS: \`${entry.name}\` [${entry.confidence}] matches ${via}`,
156 'zh-TW': `flightwake TRAPS:\`${entry.name}\` [${entry.confidence}] 命中 ${via}`,
157 'zh-CN': `flightwake TRAPS:\`${entry.name}\` [${entry.confidence}] 命中 ${via}`,
158 ja: `flightwake TRAPS: \`${entry.name}\` [${entry.confidence}] が ${via} に該当`,
159 }),
160 )
161 const detail = [entry.labelled[1], entry.labelled[2]].filter((l): l is string => !!l)
162 if (detail.length) for (const l of detail) lines.push(` ${clip(l)}`)
163 else if (entry.body) lines.push(` ${clip(entry.body.replace(/\s+/g, ' '))}`)
164 }
165 if (more > 0) {
166 lines.push(M(lang, { en: `+${more} more`, 'zh-TW': `另有 ${more} 條`, 'zh-CN': `另有 ${more} 条`, ja: `ほか ${more} 件` }))
167 }
168 lines.push(M(lang, {
169 en: 'This note arrives after this call ran: it cannot stop this one; it is for the next time you touch this.',
170 'zh-TW': '這則提示在本次呼叫執行之後才出現:它擋不了這一次,是提醒你下一次碰到這裡時注意。',
171 'zh-CN': '这则提示在本次调用执行之后才出现:它拦不住这一次,是提醒你下一次碰到这里时注意。',
172 ja: 'この注意は今回の呼び出しが実行された後に届きます。今回は防げません。次にここに触れるときのためのものです。',
173 }))
174 lines.push(M(lang, { en: `Full entry: ${TRAPS_REL}`, 'zh-TW': `完整條目:${TRAPS_REL}`, 'zh-CN': `完整条目:${TRAPS_REL}`, ja: `全文: ${TRAPS_REL}` }))
175 if (isLead) {
176 lines.push(
177 M(lang, {
178 en: 'Note: probable/suspected/unknown entries are leads, not settled facts. Verify before relying on them, and never use one to argue that something is safe.',
179 'zh-TW': '注意:probable/suspected/unknown 的條目是線索,不是定論。採信前先驗證,也不可拿它來論證某件事是安全的。',
180 'zh-CN': '注意:probable/suspected/unknown 的条目是线索,不是定论。采信前先验证,也不可拿它来论证某件事是安全的。',
181 ja: '注意: probable/suspected/unknown のエントリは手がかりであり確定事項ではありません。依拠する前に検証し、安全性の根拠には使わないでください。',
182 }),
183 )
184 }
185 return lines.join('\n')
186}
187
188/** The hint to append for this call, or null. May take `$` because it is declared in this file. */
189async function hintFor($: EngineInterface, e: Record<string, unknown>): Promise<string | null> {
190 const tool = e.tool
191 const isFile = tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit'
192 if (!isFile && tool !== 'Bash') return null
193 const target = isFile ? (str(e.file_path) ?? str(e.notebook_path)) : str(e.command)
194 if (target === null) return null
195
196 const io = ioOf($)
197 const ctx = await fwContext(io)
198 if (ctx === null) return null
199 const text = await readRel(io, ctx.root, TRAPS_REL)
200 if (text === null) return null
201 const entries = parseTraps(text).filter((t) => isActive(t) && hasMatchers(t))
202 if (!entries.length) return null
203
204 let cwd0 = ctx.root
205 if (!isFile) {
206 try { cwd0 = (await $.session.cwd()).replace(/\/+$/, '') || ctx.root } catch {}
207 }
208 const hits = isFile ? matchFile(entries, ctx.root, target) : matchBash(entries, ctx.root, cwd0, target)
209 if (!hits.length) return null
210
211 const sessionId = await io.sessionId()
212 const key = (h: Hit) => `${h.entry.name}@${h.entry.version}`
213 let shown: Hit[] = []
214 let more = 0
215 await update($, hinted, (cur) => {
216 const seen = cur && cur.sessionId === sessionId ? cur.keys : []
217 const fresh = hits.filter((h) => !seen.includes(key(h)))
218 shown = fresh.slice(0, MAX_ENTRIES)
219 more = fresh.length - shown.length
220 return { sessionId, keys: [...seen, ...shown.map(key)] }
221 })
222 if (!shown.length) return null
223 return render(ctx.lang, shown, more)
224}
225
226export function registerTripwire(on: On): void {
227 on('tool.call', {}, async ($, e, next) => {
228 const r = await next(e)
229 if (r.deny !== undefined) return r
230 try {
231 const hint = await hintFor($, e as Record<string, unknown>)
232 if (hint !== null) return { ...r, context: [...(r.context ?? []), hint] }
233 } catch {
234 // a failing tripwire never touches the person's work
235 }
236 return r
237 })
238}
239hooks/features/role-guard.ts 289 lines1/**
2 * F5 — opt-in role guard (userConfig `roleGuard`, off by default).
3 *
4 * One machine-readable rule only: the role body's own `deny-write: [glob, …]` line (hooks/lib/roles.ts). It blocks
5 * the MAIN session's Edit/Write/NotebookEdit into those paths. Natural-language "Never" bullets are never compiled
6 * into rules, no shell blacklist, Bash/MCP/Read are never touched. Subagents (an explicit assignment) are not
7 * checked; a dispatch card at the start of a prompt replaces the seat role for this session. The role is a
8 * snapshot per session id (a /clear is a new id: re-read lazily on the next tool call); edits to the role files
9 * take effect in a new session. Release: `/fw-role-release`, accepted only from the person's own composer input.
10 * This is a convenience, not a security boundary — the deny message and docs/roles.md say so.
11 */
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, On } from 'claude-code'
14
15import type { FwRoleGuard } from '../../types'
16import { fwContext, M, readRel, relToRoot, type Io, type Lang } from '../lib/core'
17import { matchGlob } from '../lib/glob'
18import { parseCard, parseSeatBlock } from '../lib/roles'
19
20// Pasted from IO_OF_TEMPLATE (hooks/lib/core.ts): `$` cannot cross an import, so each feature file builds its own.
21function ioOf($: EngineInterface): Io {
22 return {
23 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
24 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
25 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
26 read: async (p) => {
27 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
28 },
29 git: async (args, cwd) => {
30 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
31 },
32 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
33 }
34}
35
36const guardAtom = atom({ plugin: 'flightwake-mod', key: 'roleGuard' } as const, null)
37
38/** Seat files in roles.mjs order (.claude/CLAUDE.md first); the first one holding a roles block wins. */
39const SEAT_FILES = ['.claude/CLAUDE.md', 'CLAUDE.md'] as const
40
41async function readSeat(io: Io, root: string, sid: string): Promise<FwRoleGuard> {
42 for (const rel of SEAT_FILES) {
43 const t = await readRel(io, root, rel)
44 if (t === null) continue
45 const seat = parseSeatBlock(t)
46 if (seat) return { sessionId: sid, root, role: seat.id, source: 'seat', denyWrite: seat.denyWrite, released: [], isAllReleased: false }
47 }
48 return { sessionId: sid, root, role: null, source: 'none', denyWrite: [], released: [], isAllReleased: false }
49}
50
51/**
52 * This session's snapshot for this root: the stored one when it carries the current (session id, root), else read
53 * the seat of this root and store it. Another root in the same session (a /cd, a worktree move) never inherits the
54 * old seat — nor its releases.
55 */
56async function ensureSnapshot($: EngineInterface, io: Io, root: string, sid: string): Promise<FwRoleGuard> {
57 const stored = await read($, guardAtom)
58 if (stored && stored.sessionId === sid && stored.root === root) return stored
59 const fresh = await readSeat(io, root, sid)
60 await update($, guardAtom, () => fresh)
61 return fresh
62}
63
64const isReleased = (g: FwRoleGuard, glob: string): boolean => g.isAllReleased === true || g.released.includes(glob)
65const hasReleases = (g: FwRoleGuard): boolean => g.isAllReleased === true || g.released.length > 0
66
67const sourceLabel = (lang: Lang, s: FwRoleGuard['source']): string =>
68 s === 'card'
69 ? M(lang, { en: 'dispatch card', 'zh-TW': '派工卡', 'zh-CN': '派活卡', ja: '割り振りカード' })
70 : M(lang, { en: 'seat', 'zh-TW': '座位', 'zh-CN': '座位', ja: '席' })
71
72function releasedText(lang: Lang, g: FwRoleGuard): string {
73 const what = g.isAllReleased === true
74 ? M(lang, { en: 'all rules', 'zh-TW': '全部規則', 'zh-CN': '全部规则', ja: 'すべてのルール' })
75 : g.released.join(', ')
76 return M(lang, {
77 en: `⚠ role guard released: ${what} (this session)`,
78 'zh-TW': `⚠ 角色守門已放行:${what}(本 session)`,
79 'zh-CN': `⚠ 角色守门已放行:${what}(本 session)`,
80 ja: `⚠ ロールガード解除中:${what}(この session)`,
81 })
82}
83
84/** Persistent status line while releases exist; cleared otherwise. */
85function showStatus($: EngineInterface, lang: Lang, g: FwRoleGuard): void {
86 try {
87 $.ui.status(hasReleases(g) ? releasedText(lang, g) : undefined)
88 } catch {}
89}
90
91/** A visible trace in the transcript (a notice the model never reads). Silent when the engine refuses it. */
92async function trace($: EngineInterface, text: string): Promise<void> {
93 try {
94 await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } })
95 } catch {}
96}
97
98function listText(lang: Lang, g: FwRoleGuard): string {
99 const head = M(lang, {
100 en: `role guard — role: ${g.role}, from the ${sourceLabel(lang, g.source)}`,
101 'zh-TW': `角色守門——角色:${g.role},來源:${sourceLabel(lang, g.source)}`,
102 'zh-CN': `角色守门——角色:${g.role},来源:${sourceLabel(lang, g.source)}`,
103 ja: `ロールガード — ロール:${g.role}(${sourceLabel(lang, g.source)}由来)`,
104 })
105 const none = M(lang, { en: '(none)', 'zh-TW': '(無)', 'zh-CN': '(无)', ja: '(なし)' })
106 const rules = g.denyWrite.map((r, i) => {
107 const mark = isReleased(g, r) ? M(lang, { en: ' [released]', 'zh-TW': ' [已放行]', 'zh-CN': ' [已放行]', ja: ' [解除中]' }) : ''
108 return ` ${i + 1}. ${r}${mark}`
109 })
110 const usage = M(lang, {
111 en: 'Usage: /fw-role-release <glob|number> · all · revoke',
112 'zh-TW': '用法:/fw-role-release <glob|編號> · all · revoke',
113 'zh-CN': '用法:/fw-role-release <glob|编号> · all · revoke',
114 ja: '使い方:/fw-role-release <glob|番号> · all · revoke',
115 })
116 return [head, ...(rules.length ? rules : [` ${none}`]), usage].join('\n')
117}
118
119async function runRelease($: EngineInterface, io: Io, root: string, lang: Lang, args: string): Promise<string> {
120 const sid = await io.sessionId()
121 const g = await ensureSnapshot($, io, root, sid)
122 if (!g || g.role === null || g.denyWrite.length === 0) {
123 return M(lang, {
124 en: 'role guard: nothing to release (no role in force, or its role has no deny-write rules).',
125 'zh-TW': '角色守門:沒有可放行的項目(目前沒有角色,或該角色沒有 deny-write 規則)。',
126 'zh-CN': '角色守门:没有可放行的项目(当前没有角色,或该角色没有 deny-write 规则)。',
127 ja: 'ロールガード:解除するものはありません(ロールがないか、そのロールに deny-write ルールがありません)。',
128 })
129 }
130 const arg = args.trim()
131 if (arg === '') return listText(lang, g)
132
133 let next: string[]
134 let nextAll: boolean
135 let done: string
136 if (arg === 'revoke') {
137 next = []
138 nextAll = false
139 done = M(lang, {
140 en: 'role guard: releases revoked, rules apply again.',
141 'zh-TW': '角色守門:已收回放行,規則恢復生效。',
142 'zh-CN': '角色守门:已收回放行,规则恢复生效。',
143 ja: 'ロールガード:解除を取り消しました。ルールが再び有効です。',
144 })
145 } else {
146 const isAll = arg === 'all'
147 let glob: string | null = null
148 if (isAll) glob = null
149 else if (/^\d+$/.test(arg)) glob = g.denyWrite[Number(arg) - 1] ?? null
150 else glob = g.denyWrite.includes(arg) ? arg : null
151 if (!isAll && glob === null) {
152 return (
153 M(lang, {
154 en: `role guard: "${arg}" is not one of this role's rules.`,
155 'zh-TW': `角色守門:「${arg}」不是此角色的規則之一。`,
156 'zh-CN': `角色守门:「${arg}」不是此角色的规则之一。`,
157 ja: `ロールガード:「${arg}」はこのロールのルールではありません。`,
158 }) +
159 '\n' +
160 listText(lang, g)
161 )
162 }
163 nextAll = isAll || g.isAllReleased === true
164 next = isAll || glob === null ? [...g.released] : [...new Set([...g.released, glob])]
165 const label = isAll ? M(lang, { en: 'all rules', 'zh-TW': '全部規則', 'zh-CN': '全部规则', ja: 'すべてのルール' }) : (glob as string)
166 done = M(lang, {
167 en: `role guard: released ${label} for this session.`,
168 'zh-TW': `角色守門:本 session 已放行 ${label}。`,
169 'zh-CN': `角色守门:本 session 已放行 ${label}。`,
170 ja: `ロールガード:この session では ${label} を解除しました。`,
171 })
172 }
173 const after: FwRoleGuard = { ...g, released: next, isAllReleased: nextAll }
174 await update($, guardAtom, (cur) => (cur && cur.sessionId === sid && cur.root === root ? { ...cur, released: next, isAllReleased: nextAll } : after))
175 showStatus($, lang, after)
176 await trace($, done)
177 return done
178}
179
180export function registerRoleGuard(on: On): void {
181 on('session.start', {}, async ($, e, next) => {
182 try {
183 const io = ioOf($)
184 const ctx = await fwContext(io) // same install test as every feature: no .flightwake/STATE.md → no guard
185 if (ctx !== null) {
186 const { root, lang } = ctx
187 try {
188 await $.command.register({
189 name: 'fw-role-release',
190 description: M(lang, {
191 en: 'Release the role guard (deny-write rules) for this session',
192 'zh-TW': '放行本 session 的角色守門(deny-write 規則)',
193 'zh-CN': '放行本 session 的角色守门(deny-write 规则)',
194 ja: 'この session のロールガード(deny-write ルール)を解除する',
195 }),
196 argumentHint: '[glob|number|all|revoke]',
197 })
198 } catch {}
199 const sid = await io.sessionId()
200 // Reload keeps the stored snapshot (role and releases); a new session id re-reads the seat.
201 const g = await ensureSnapshot($, io, root, sid)
202 if (hasReleases(g)) showStatus($, lang, g)
203 }
204 } catch {}
205 return next(e)
206 })
207
208 on('prompt.submit', {}, async ($, e, next) => {
209 try {
210 if (e.origin?.kind !== 'plugin') {
211 const card = parseCard(e.text)
212 if (card) {
213 const io = ioOf($)
214 const sid = await io.sessionId()
215 const ctx = await fwContext(io)
216 if (ctx !== null) {
217 const { root, lang } = ctx
218 const g: FwRoleGuard = { sessionId: sid, root, role: card.id, source: 'card', denyWrite: card.denyWrite, released: [], isAllReleased: false }
219 await update($, guardAtom, () => g)
220 showStatus($, lang, g)
221 }
222 }
223 }
224 } catch {}
225 return next(e)
226 })
227
228 on('tool.call', {}, async ($, e, next) => {
229 try {
230 // Main loop only: a subagent (incl. a spawned on-call role) is an explicit assignment that overrides the seat.
231 if (e.agentId !== undefined) return next(e)
232 let target: string | undefined
233 if (e.tool === 'Edit' || e.tool === 'Write') target = e.file_path
234 else if (e.tool === 'NotebookEdit') target = e.notebook_path
235 if (typeof target !== 'string' || target === '') return next(e)
236
237 const io = ioOf($)
238 const ctx = await fwContext(io)
239 if (ctx === null) return next(e)
240 const { root, lang } = ctx
241 const rel = relToRoot(root, target)
242 if (rel === null || rel === '') return next(e)
243 const g = await ensureSnapshot($, io, root, await io.sessionId())
244 if (g.role === null || g.denyWrite.length === 0) return next(e)
245 // Every rule the path falls under must be released: releasing src/** never releases src/private/**.
246 const blocking = g.denyWrite.filter((r) => matchGlob(r, rel) && !isReleased(g, r))
247 if (blocking.length === 0) return next(e)
248 const glob = blocking.join(', ')
249 const how = blocking.map((r) => `/fw-role-release ${r}`).join(', ')
250
251 const src = sourceLabel(lang, g.source)
252 return {
253 deny: M(lang, {
254 en: `Role guard: this session's role "${g.role}" (from the ${src}) may not write ${rel} (rule deny-write: ${glob}). Dispatch the work to the role that owns it, or call an on-call role or a worker (\`npx flightwake roles card <id>\`); or ask the user to run ${how} for this session. This guard is a convenience, not a security boundary: Bash and other tools are not checked.`,
255 'zh-TW': `角色守門:本 session 的角色「${g.role}」(來自${src})不可寫入 ${rel}(規則 deny-write: ${glob})。請把這件事派給負責的角色,或召喚待命角色/用 \`npx flightwake roles card <id>\` 派給 worker;也可以請使用者對本 session 執行 ${how}。這道守門只是個方便,不是安全邊界:Bash 與其他工具不受檢查。`,
256 'zh-CN': `角色守门:本 session 的角色「${g.role}」(来自${src})不可写入 ${rel}(规则 deny-write: ${glob})。请把这件事派给负责的角色,或召唤待命角色/用 \`npx flightwake roles card <id>\` 派给 worker;也可以请用户对本 session 执行 ${how}。这道守门只是个方便,不是安全边界:Bash 与其他工具不受检查。`,
257 ja: `ロールガード:この session のロール「${g.role}」(${src}由来)は ${rel} を書き込めません(ルール deny-write: ${glob})。担当のロールに割り振るか、オンコールロールを呼ぶか、\`npx flightwake roles card <id>\` で worker に渡してください。または本人にこの session で ${how} を実行してもらってください。このガードは便宜であってセキュリティ境界ではありません:Bash などほかのツールは検査されません。`,
258 }),
259 }
260 } catch {
261 return next(e)
262 }
263 })
264
265 on('command.run', { command: 'fw-role-release' }, async ($, e, next) => {
266 try {
267 const io = ioOf($)
268 const ctx = await fwContext(io)
269 if (ctx === null) return next(e) // not installed here: as if the command did not exist
270 const lang = ctx.lang
271 // Only the person's own Enter at the prompt (origin composer). Everything else — a plugin, the bridge, the SDK,
272 // a peer, a schedule, and an unstamped origin — is refused: nothing the model can cause releases the guard.
273 if (e.origin?.kind !== 'composer') {
274 return {
275 text: M(lang, {
276 en: 'role guard: /fw-role-release only works when you type it yourself in the prompt. Nothing changed.',
277 'zh-TW': '角色守門:/fw-role-release 只接受你自己在輸入框打出的指令。未做任何變更。',
278 'zh-CN': '角色守门:/fw-role-release 只接受你自己在输入框里敲出的命令。未做任何更改。',
279 ja: 'ロールガード:/fw-role-release は、あなた自身がプロンプトに入力したときだけ有効です。何も変更していません。',
280 }),
281 }
282 }
283 return { text: await runRelease($, io, ctx.root, lang, e.args) }
284 } catch {
285 return next(e)
286 }
287 })
288}
289hooks/features/status.ts 190 lines1/**
2 * /fw-mod — what each of the five features is doing right now and why (2026-10-05 field feedback: the mod is
3 * designed not to interrupt, so after installing nobody could tell whether it had loaded or which features were
4 * acting). Read-only: it reads what the features themselves read (STATE, TRAPS, the instruction-file markers and the
5 * seat block, settings) and the features' own $.state values; it changes nothing. Registered regardless of the five
6 * switches — it is how you see them — but only in a folder where flightwake is installed.
7 */
8import type { EngineInterface, On } from 'claude-code'
9
10import type { FwFlightLog, FwRoleGuard, FwStateSnapshot } from '../../types'
11import { detectProfile, fwContext, isUninitializedState, legacyStatuslineActive, M, MOD_VERSION, readRel, STATE_REL, TRAPS_REL } from '../lib/core'
12import type { Io, Lang } from '../lib/core'
13import { parseSeatBlock } from '../lib/roles'
14import { hasMatchers, isActive, parseTraps } from '../lib/traps'
15
16export type Switches = { stateInject: boolean; band: boolean; recorder: boolean; tripwire: boolean; roleGuard: boolean }
17
18// The Io closure over this file's `$` (IO_OF_TEMPLATE in hooks/lib/core.ts; `$` may not cross an import).
19function ioOf($: EngineInterface): Io {
20 return {
21 root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
22 sessionId: async () => { try { return await $.session.id() } catch { return '' } },
23 exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
24 read: async (p) => {
25 try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
26 },
27 git: async (args, cwd) => {
28 try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
29 },
30 settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
31 }
32}
33
34const SEAT_FILES = ['.claude/CLAUDE.md', 'CLAUDE.md'] as const
35// The loader holds every $.state reference to literal plugin/key values written in this file
36const SNAPSHOT_REF = { plugin: 'flightwake-mod', key: 'stateSnapshot' } as const
37const LOG_REF = { plugin: 'flightwake-mod', key: 'flightLog' } as const
38const GUARD_REF = { plugin: 'flightwake-mod', key: 'roleGuard' } as const
39
40/** How to switch a feature on or off yourself (plugin options are not read from project settings). */
41const howTo = (lang: Lang, key: keyof Switches, value: boolean): string => {
42 const json = `"pluginConfigs": { "flightwake-mod@skills-dir": { "options": { "${key}": ${value} } } }`
43 return M(lang, {
44 en: `turn it ${value ? 'on' : 'off'} in /config, or in your user settings (~/.claude/settings.json): ${json}`,
45 'zh-TW': `在 /config ${value ? '開啟' : '關閉'},或寫進你的使用者設定(~/.claude/settings.json):${json}`,
46 'zh-CN': `在 /config ${value ? '开启' : '关闭'},或写进你的用户设置(~/.claude/settings.json):${json}`,
47 ja: `/config で${value ? 'オン' : 'オフ'}にするか、ユーザー設定(~/.claude/settings.json)に:${json}`,
48 })
49}
50const ON = (lang: Lang) => M(lang, { en: 'on', 'zh-TW': '開啟', 'zh-CN': '开启', ja: 'オン' })
51const OFF = (lang: Lang) => M(lang, { en: 'off', 'zh-TW': '關閉', 'zh-CN': '关闭', ja: 'オフ' })
52const IDLE = (lang: Lang) => M(lang, { en: 'idle', 'zh-TW': '閒置', 'zh-CN': '闲置', ja: '待機' })
53
54const LABEL: Record<keyof Switches, Record<Lang, string>> = {
55 stateInject: { en: 'STATE at session start', 'zh-TW': 'STATE 自動載入', 'zh-CN': 'STATE 自动载入', ja: 'STATE の自動読み込み' },
56 band: { en: 'band above the prompt', 'zh-TW': '輸入框上方橫條', 'zh-CN': '输入框上方横条', ja: '入力欄上の帯' },
57 recorder: { en: 'session flight log', 'zh-TW': 'session 行車記錄', 'zh-CN': 'session 行车记录', ja: 'セッション記録' },
58 tripwire: { en: 'trap tripwire', 'zh-TW': '踩坑絆線', 'zh-CN': '踩坑绊线', ja: '落とし穴の検知線' },
59 roleGuard: { en: 'role guard', 'zh-TW': '角色守門', 'zh-CN': '角色守门', ja: 'ロールガード' },
60}
61const line = (lang: Lang, key: keyof Switches, state: string, why: string): string => `- **${LABEL[key][lang]}** (\`${key}\`): ${state} — ${why}`
62
63async function statusText($: EngineInterface, sw: Switches): Promise<string | null> {
64 const io = ioOf($)
65 const ctx = await fwContext(io)
66 if (ctx === null) return null
67 const { root, lang } = ctx
68 const sid = await io.sessionId()
69 const state = await readRel(io, root, STATE_REL)
70 const out: string[] = []
71 out.push(`## flightwake-mod v${MOD_VERSION}`, '')
72 out.push(M(lang, {
73 en: `language: ${lang} · profile: ${await detectProfile(io, root)} · root: ${root}`,
74 'zh-TW': `語言(language): ${lang} · 類型(profile): ${await detectProfile(io, root)} · 根目錄: ${root}`,
75 'zh-CN': `语言(language): ${lang} · 类型(profile): ${await detectProfile(io, root)} · 根目录: ${root}`,
76 ja: `言語(language): ${lang} · 種別(profile): ${await detectProfile(io, root)} · ルート: ${root}`,
77 }), '')
78
79 // F1
80 if (!sw.stateInject) out.push(line(lang, 'stateInject', OFF(lang), howTo(lang, 'stateInject', true)))
81 else {
82 let snap: FwStateSnapshot | null = null
83 try { snap = (await $.state.get(SNAPSHOT_REF)).value ?? null } catch {}
84 const isThisSession = snap !== null && snap.sessionId === sid && snap.text !== null
85 out.push(line(lang, 'stateInject', ON(lang), state !== null && isUninitializedState(state)
86 ? M(lang, { en: 'STATE is not initialized yet, so only a "run /fw-coldstart" note is injected', 'zh-TW': 'STATE 尚未初始化,所以只注入「請先跑 /fw-coldstart」的提示', 'zh-CN': 'STATE 尚未初始化,所以只注入「请先跑 /fw-coldstart」的提示', ja: 'STATE が未初期化のため「まず /fw-coldstart」という注記だけを注入' })
87 : isThisSession
88 ? M(lang, { en: 'STATE was injected into this session (a snapshot taken at session start)', 'zh-TW': '已在本 session 注入 STATE(session 開始時取的快照)', 'zh-CN': '已在本 session 注入 STATE(session 开始时取的快照)', ja: 'このセッションに STATE を注入済み(セッション開始時のスナップショット)' })
89 : M(lang, { en: 'nothing injected yet in this session (it is taken when a session starts)', 'zh-TW': '本 session 尚未注入(在 session 開始時取得)', 'zh-CN': '本 session 尚未注入(在 session 开始时取得)', ja: 'このセッションではまだ注入していない(セッション開始時に取得)' })))
90 }
91
92 // F2
93 if (!sw.band) out.push(line(lang, 'band', OFF(lang), howTo(lang, 'band', true)))
94 else out.push(line(lang, 'band', ON(lang), (await legacyStatuslineActive(io))
95 ? M(lang, { en: 'hiding the fields the bottom gauge already shows (health / STATE lag / context); only the one-time 80% context toast remains', 'zh-TW': '偵測到底部儀表,隱藏儀表已顯示的欄位(health/STATE 落後/context),只保留 context 80% 時的一次提示', 'zh-CN': '检测到底部仪表,隐藏仪表已显示的栏位(health/STATE 落后/context),只保留 context 80% 时的一次提示', ja: '下部ゲージを検出したため、ゲージが表示する欄(health / STATE の遅れ / context)を隠し、context 80% の一度きりのトーストだけ残す' })
96 : M(lang, { en: 'shown above the prompt with health, STATE lag and context use (it stands in for the bottom gauge)', 'zh-TW': '顯示在輸入框上方:health、STATE 落後、context 用量(代替底部儀表)', 'zh-CN': '显示在输入框上方:health、STATE 落后、context 用量(代替底部仪表)', ja: '入力欄の上に health・STATE の遅れ・context 使用量を表示(下部ゲージの代わり)' })))
97
98 // F3
99 if (!sw.recorder) out.push(line(lang, 'recorder', OFF(lang), howTo(lang, 'recorder', true)))
100 else {
101 let raw: FwFlightLog | null = null
102 try { raw = (await $.state.get(LOG_REF)).value ?? null } catch {}
103 const log = raw !== null && raw.sessionId === sid ? raw : null
104 const n = { files: log?.files.length ?? 0, shell: log?.shellFiles?.length ?? 0, tests: log?.tests.length ?? 0, commits: log?.commits.length ?? 0 }
105 out.push(line(lang, 'recorder', ON(lang), M(lang, {
106 en: `this session: ${n.files} file(s) by tools, ${n.shell} via shell (inferred), ${n.tests} test run(s), ${n.commits} commit(s) — /fw-log prints them`,
107 'zh-TW': `本 session:工具改了 ${n.files} 個檔、shell 推斷 ${n.shell} 個、測試 ${n.tests} 次、commit ${n.commits} 個 — /fw-log 印出明細`,
108 'zh-CN': `本 session:工具改了 ${n.files} 个文件、shell 推断 ${n.shell} 个、测试 ${n.tests} 次、commit ${n.commits} 个 — /fw-log 打印明细`,
109 ja: `このセッション:ツールで ${n.files} 件、シェル推定 ${n.shell} 件、テスト ${n.tests} 回、commit ${n.commits} 件 — /fw-log で詳細`,
110 })))
111 }
112
113 // F4
114 if (!sw.tripwire) out.push(line(lang, 'tripwire', OFF(lang), howTo(lang, 'tripwire', true)))
115 else {
116 const traps = await readRel(io, root, TRAPS_REL)
117 const watched = traps === null ? 0 : parseTraps(traps).filter((t) => isActive(t) && hasMatchers(t)).length
118 out.push(watched === 0
119 ? line(lang, 'tripwire', IDLE(lang), M(lang, { en: 'no active TRAPS entry has paths or commands — add them to an entry to be warned (fw-trap explains the two fields)', 'zh-TW': 'TRAPS 裡沒有任何帶 paths 或 commands 的 active 條目 — 在條目加上這兩個欄位才會提示(fw-trap 有說明)', 'zh-CN': 'TRAPS 里没有任何带 paths 或 commands 的 active 条目 — 在条目加上这两个栏位才会提示(fw-trap 有说明)', ja: 'TRAPS に paths か commands を持つ active な項目がない — 項目にこの 2 欄を足すとヒントが出る(fw-trap に説明あり)' }))
120 : line(lang, 'tripwire', ON(lang), M(lang, { en: `watching ${watched} TRAPS entr${watched === 1 ? 'y' : 'ies'} with paths / commands`, 'zh-TW': `監看 ${watched} 條帶 paths / commands 的 TRAPS 條目`, 'zh-CN': `监看 ${watched} 条带 paths / commands 的 TRAPS 条目`, ja: `paths / commands を持つ TRAPS 項目 ${watched} 件を監視中` })))
121 }
122
123 // F5
124 const notBoundary = M(lang, { en: 'not a security boundary', 'zh-TW': '不是安全邊界', 'zh-CN': '不是安全边界', ja: 'セキュリティ境界ではない' })
125 if (!sw.roleGuard) out.push(line(lang, 'roleGuard', OFF(lang), `${M(lang, { en: 'opt-in', 'zh-TW': '選配', 'zh-CN': '选配', ja: 'オプトイン' })}; ${howTo(lang, 'roleGuard', true)} (${notBoundary})`))
126 else {
127 let snap: FwRoleGuard | null = null
128 try { snap = (await $.state.get(GUARD_REF)).value ?? null } catch {}
129 let role: string | null = null
130 let deny: string[] = []
131 let source = 'seat'
132 let released: string[] = []
133 if (snap !== null && snap.sessionId === sid && snap.root === root) {
134 role = snap.role; deny = snap.denyWrite; source = snap.source; released = snap.isAllReleased ? ['*all*'] : snap.released
135 } else {
136 for (const rel of SEAT_FILES) {
137 const t = await readRel(io, root, rel)
138 const seat = t === null ? null : parseSeatBlock(t)
139 if (seat) { role = seat.id; deny = seat.denyWrite; break }
140 }
141 }
142 if (role === null) out.push(line(lang, 'roleGuard', IDLE(lang), M(lang, { en: "this folder's Claude seat has no role (roles apply writes one into CLAUDE.md)", 'zh-TW': '這個資料夾的 Claude 座位沒有角色(roles apply 會寫進 CLAUDE.md)', 'zh-CN': '这个文件夹的 Claude 座位没有角色(roles apply 会写进 CLAUDE.md)', ja: 'このフォルダーの Claude の席にロールがない(roles apply が CLAUDE.md に書く)' })))
143 else if (deny.length === 0) out.push(line(lang, 'roleGuard', IDLE(lang), M(lang, { en: `role ${role} has no deny-write line, so there is nothing to guard`, 'zh-TW': `角色 ${role} 沒有 deny-write,沒有要守的路徑`, 'zh-CN': `角色 ${role} 没有 deny-write,没有要守的路径`, ja: `ロール ${role} に deny-write がなく、守る対象がない` })))
144 else out.push(line(lang, 'roleGuard', ON(lang), M(lang, {
145 en: `role ${role} (${source}) blocks Edit/Write into ${deny.join(', ')}${released.length ? `; released this session: ${released.join(', ')}` : ''} (${notBoundary})`,
146 'zh-TW': `角色 ${role}(${source})擋下 Edit/Write 寫入 ${deny.join(', ')}${released.length ? `;本 session 已放行:${released.join(', ')}` : ''}(${notBoundary})`,
147 'zh-CN': `角色 ${role}(${source})挡下 Edit/Write 写入 ${deny.join(', ')}${released.length ? `;本 session 已放行:${released.join(', ')}` : ''}(${notBoundary})`,
148 ja: `ロール ${role}(${source})が ${deny.join(', ')} への Edit/Write を止める${released.length ? `。このセッションで解除:${released.join(', ')}` : ''}(${notBoundary})`,
149 })))
150 }
151 out.push('', M(lang, { en: 'Read-only: /fw-mod changes nothing.', 'zh-TW': '唯讀:/fw-mod 不改任何東西。', 'zh-CN': '只读:/fw-mod 不改任何东西。', ja: '読み取り専用:/fw-mod は何も変更しない。' }))
152 return out.join('\n')
153}
154
155let isCommandRegistered = false
156async function ensureCommand($: EngineInterface): Promise<void> {
157 if (isCommandRegistered) return
158 isCommandRegistered = true
159 try {
160 const ctx = await fwContext(ioOf($))
161 if (ctx === null) { isCommandRegistered = false; return }
162 await $.command.register({
163 name: 'fw-mod',
164 description: M(ctx.lang, {
165 en: 'Show what each flightwake-mod feature is doing and why (read-only)',
166 'zh-TW': '列出 flightwake-mod 各功能的狀態與原因(唯讀)',
167 'zh-CN': '列出 flightwake-mod 各功能的状态与原因(只读)',
168 ja: 'flightwake-mod の各機能の状態と理由を表示(読み取り専用)',
169 }),
170 })
171 } catch {
172 isCommandRegistered = false
173 }
174}
175
176export function registerStatus(on: On, switches: Switches): void {
177 on('session.start', {}, async ($, e, next) => {
178 await ensureCommand($)
179 return next(e)
180 })
181 on('command.run', { command: 'fw-mod' }, async ($, e, next) => {
182 try {
183 const text = await statusText($, switches)
184 return text === null ? next(e) : { text }
185 } catch {
186 return next(e)
187 }
188 })
189}
190hooks/lib/core.ts 294 lines1/**
2 * Shared world access for every feature.
3 *
4 * READ SCOPE (docs/plans/mods.md, revised 2026-10-05) — the mod reads only:
5 * .flightwake/ · git state · the flightwake / roles markers in CLAUDE.md, .claude/CLAUDE.md, CLAUDE.local.md ·
6 * the flightwake marker in AGENTS.md / GEMINI.md (language only) · package.json `scripts` · the effective
7 * `statusLine` setting. Nothing else, and no network. Everything outside the module goes through `$`; every helper here
8 * swallows its own errors and answers null/false, so a feature built on them degrades silently (no
9 * .flightwake/, not a git repo, git missing, unreadable file) instead of surfacing an error to the person.
10 * Read-only by contract: nothing here writes outside the mod's own $.state / $.store.
11 */
12
13export const PLUGIN = 'flightwake-mod' as const
14/** Mirrors .claude-plugin/plugin.json `version` (the mod cannot read its own manifest); test/smoke.sh checks they match. */
15export const MOD_VERSION = '0.1.0'
16export const FW_DIR = '.flightwake'
17export const STATE_REL = '.flightwake/STATE.md'
18export const TRAPS_REL = '.flightwake/TRAPS.md'
19
20export type Lang = 'en' | 'zh-TW' | 'zh-CN' | 'ja'
21export const LANGS: readonly Lang[] = ['en', 'zh-TW', 'zh-CN', 'ja']
22/** A message in the four install languages; a missing key falls back to English (same rule as hooks/*.mjs). */
23export type Msg = { en: string } & Partial<Record<Exclude<Lang, 'en'>, string>>
24export const M = (lang: Lang, m: Msg): string => m[lang] ?? m.en
25
26/** One markdown table cell: newlines become spaces, then `\` before `|` is escaped so a `\|` in the text stays unambiguous. */
27export const tableCell = (s: string): string => s.replace(/\r?\n/g, ' ').replace(/\\/g, '\\\\').replace(/\|/g, '\\|')
28
29/**
30 * The world as the shared helpers see it. `$` can't be passed across an import — the engine's loader refuses a
31 * module that does (`$ is followed only into a function declared in this same file, never across an import`) —
32 * so each feature file builds this from its own `$` with a local `ioOf($)` (copy IO_OF_TEMPLATE below) and passes
33 * the closures in. Every member must swallow its own errors (null / false / {}), never throw.
34 */
35export type Io = {
36 /** $.session.root(), absolute, no trailing slash; '' when unreadable. */
37 root: () => Promise<string>
38 /** $.session.id(); '' when unreadable. */
39 sessionId: () => Promise<string>
40 /** $.fs.exists(abs); false on error. */
41 exists: (abs: string) => Promise<boolean>
42 /** $.fs.read(abs) as text; null when absent/unreadable. */
43 read: (abs: string) => Promise<string | null>
44 /**
45 * `git --no-optional-locks <args>` in cwd: trimmed stdout on exit 0, null otherwise (not a repo, git missing,
46 * timeout). The flag is part of the zero-write promise: a plain `git status` may refresh and rewrite .git/index.
47 */
48 git: (args: readonly string[], cwd: string) => Promise<string | null>
49 /** $.settings.read({}) — the merged, effective settings; {} on error. */
50 settings: () => Promise<Record<string, unknown>>
51}
52
53/*
54 * IO_OF_TEMPLATE — paste into a feature file (it must live in the same file as the hooks that call it):
55 *
56 * import type { EngineInterface } from 'claude-code'
57 * import type { Io } from '../lib/core'
58 *
59 * function ioOf($: EngineInterface): Io {
60 * return {
61 * root: async () => { try { return (await $.session.root()).replace(/\/+$/, '') } catch { return '' } },
62 * sessionId: async () => { try { return await $.session.id() } catch { return '' } },
63 * exists: async (p) => { try { return await $.fs.exists(p) } catch { return false } },
64 * read: async (p) => {
65 * try { if (!(await $.fs.exists(p))) return null; const t = await $.fs.read(p); return typeof t === 'string' ? t : null } catch { return null }
66 * },
67 * git: async (args, cwd) => {
68 * try { const r = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : null } catch { return null }
69 * },
70 * settings: async () => { try { return await $.settings.read({}) } catch { return {} } },
71 * }
72 * }
73 */
74
75
76export const joinPath = (root: string, rel: string): string => `${root}/${rel.replace(/^\/+/, '')}`
77
78/**
79 * `abs` relative to `root` with forward slashes, or null when it lies outside root.
80 * Purely lexical: collapses `.`/`..` segments; does not resolve links.
81 */
82export function relToRoot(root: string, abs: string): string | null {
83 const norm = (p: string) => {
84 const out: string[] = []
85 for (const seg of p.split('/')) {
86 if (seg === '' || seg === '.') continue
87 if (seg === '..') out.pop()
88 else out.push(seg)
89 }
90 return out
91 }
92 const r = norm(root)
93 const a = norm(abs.startsWith('/') ? abs : `${root}/${abs}`)
94 if (a.length < r.length || r.some((s, i) => a[i] !== s)) return null
95 return a.slice(r.length).join('/')
96}
97
98/** A text file under the root, or null when absent/unreadable. */
99export const readRel = (io: Io, root: string, rel: string): Promise<string | null> => io.read(joinPath(root, rel))
100
101/** `git <args>` in root (see Io.git). */
102export const git = (io: Io, root: string, args: readonly string[]): Promise<string | null> => io.git(args, root)
103
104/** The YAML-ish frontmatter block of a Markdown file as flat key → raw string (inline comments stripped). */
105export function parseFrontmatter(text: string): Record<string, string> | null {
106 const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
107 if (!m || m[1] === undefined) return null
108 return parseFlatYaml(m[1])
109}
110
111/** `key: value` lines → record; `# comment` tails and surrounding quotes removed; nested/continuation lines ignored. */
112export function parseFlatYaml(block: string): Record<string, string> {
113 const out: Record<string, string> = {}
114 for (const line of block.split(/\r?\n/)) {
115 const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
116 if (!m || m[1] === undefined) continue
117 let v = (m[2] ?? '').replace(/\s+#.*$/, '').trim()
118 if (/^(['"]).*\1$/.test(v)) v = v.slice(1, -1)
119 out[m[1]] = v
120 }
121 return out
122}
123
124/** An inline YAML list (`[a, "b c", 'd']`) or a single scalar → items; empty input → []. */
125export function parseInlineList(v: string | undefined): string[] {
126 if (!v) return []
127 const s = v.trim()
128 const body = s.startsWith('[') && s.endsWith(']') ? s.slice(1, -1) : s
129 const items: string[] = []
130 const re = /\s*("([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|([^,]+))\s*(?:,|$)/g
131 let m: RegExpExecArray | null
132 while ((m = re.exec(body)) !== null && m[0] !== '') {
133 const item = (m[2] ?? m[3] ?? m[4] ?? '').trim()
134 if (item) items.push(item)
135 }
136 return items
137}
138
139/**
140 * Files whose flightwake marker carries the install language, in bin/cli.mjs detectMarker order: Claude's first,
141 * then AGENTS.md / GEMINI.md (read for the marker only — a repo installed for Codex/Gemini alone has just those).
142 */
143export const MARKER_FILES = ['.claude/CLAUDE.md', 'CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md', 'GEMINI.md'] as const
144/** `<!-- flightwake:begin v<version> [attr=value …] -->`; attributes in any order (e.g. `lang=zh-TW profile=notes`). */
145const MARKER_RE = /<!-- flightwake:begin v(\d+\.\d+\.\d+\S*)((?:\s+[\w-]+=[^\s>]+)*)\s*-->/
146
147/** The language attribute of a marker line, or what its absence means; null when the text holds no marker. */
148export function markerLang(text: string): Lang | null {
149 const m = MARKER_RE.exec(text)
150 if (!m) return null
151 const attrs = m[2] ?? ''
152 const lang = /(?:^|\s)lang=([\w-]+)/.exec(attrs)?.[1]
153 // A pre-0.9 marker carried no attributes at all, and every install back then was zh-TW. A marker that has other
154 // attributes but no lang is a newer writer that left the language out: fall back to the default, English.
155 if (lang === undefined) return attrs.trim() === '' ? 'zh-TW' : 'en'
156 return (LANGS as readonly string[]).includes(lang) ? (lang as Lang) : 'en'
157}
158
159/** The profile attribute of a marker (`profile=notes`); absent = code; null when the text holds no marker. */
160export function markerProfile(text: string): 'code' | 'notes' | null {
161 const m = MARKER_RE.exec(text)
162 if (!m) return null
163 return /(?:^|\s)profile=notes(?:\s|$)/.test(m[2] ?? '') ? 'notes' : 'code'
164}
165
166/** The profile recorded at install time, from the same first marker detectLang reads; none → code. */
167export async function detectProfile(io: Io, root: string): Promise<'code' | 'notes'> {
168 for (const rel of MARKER_FILES) {
169 const t = await readRel(io, root, rel)
170 if (t === null) continue
171 const p = markerProfile(t)
172 if (p !== null) return p
173 }
174 return 'code'
175}
176
177/** The language recorded at install time: the first marker found in MARKER_FILES; none → en. */
178export async function detectLang(io: Io, root: string): Promise<Lang> {
179 for (const rel of MARKER_FILES) {
180 const t = await readRel(io, root, rel)
181 if (t === null) continue
182 const l = markerLang(t)
183 if (l !== null) return l
184 }
185 return 'en'
186}
187
188export type Health = 'green' | 'yellow' | 'red' | 'unknown'
189
190/** health from STATE frontmatter (`health: green # …`); anything unrecognised is 'unknown'. */
191export function healthOf(stateText: string): Health {
192 const h = /^health:\s*(\S+)/m.exec(stateText)?.[1]
193 return h === 'green' || h === 'yellow' || h === 'red' ? h : 'unknown'
194}
195
196/** The shipped STATE template's own frontmatter placeholders (identical in all four languages). */
197const TEMPLATE_FIELDS = ['{{DATE}}', '{{SESSION_OR_PERSON}}', '{{YYMMDD}}', '{{slug}}'] as const
198
199/**
200 * STATE is still the unfilled template: its frontmatter still holds one of the template's own placeholders.
201 * Only those count — a filled STATE may legitimately document `{{customer}}`-style placeholders in its body.
202 */
203export function isUninitializedState(stateText: string): boolean {
204 const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(stateText)
205 if (!m || m[1] === undefined) return false
206 const front = m[1]
207 return TEMPLATE_FIELDS.some((f) => front.includes(f))
208}
209
210/**
211 * STATE lag with state-check.mjs's full semantics:
212 * - `dirty`: STATE.md has uncommitted changes → an update is in progress, counts as fresh
213 * - `no-baseline`: STATE.md was never committed → not measured (state-check stays quiet)
214 * - `behind`: human commits since STATE's last commit (`--author=\[bot\]` excluded); 0 = in sync
215 * - `error`: a git call failed midway → unknown; callers must NEVER render this as "in sync"
216 * null: not a git repo / git unavailable (nothing to say).
217 */
218export type StateLag =
219 | { kind: 'dirty' }
220 | { kind: 'no-baseline' }
221 | { kind: 'behind'; behind: number }
222 | { kind: 'error' }
223
224export async function stateLag(io: Io, root: string): Promise<StateLag | null> {
225 const status = await git(io, root, ['status', '--porcelain', '--', STATE_REL])
226 if (status === null) return null
227 if (status !== '') return { kind: 'dirty' }
228 const last = await git(io, root, ['log', '-1', '--format=%H', '--', STATE_REL])
229 if (last === null) return { kind: 'error' }
230 if (last === '') return { kind: 'no-baseline' }
231 const range = `${last}..HEAD`
232 const all = await git(io, root, ['rev-list', '--count', range])
233 const bots = await git(io, root, ['rev-list', '--count', '--author=\\[bot\\]', range])
234 const a = Number(all)
235 const b = Number(bots)
236 if (all === null || bots === null || all === '' || bots === '' || !Number.isFinite(a) || !Number.isFinite(b)) {
237 return { kind: 'error' }
238 }
239 return { kind: 'behind', behind: Math.max(0, a - b) }
240}
241
242/**
243 * Whether the legacy node gauge (hooks/statusline.mjs) is the *effective* statusLine: the settings as the engine
244 * runs under them (every source merged), not whether a settings file mentions it. The mod only reads this to
245 * hide its own duplicate fields; it never edits settings.
246 */
247export async function legacyStatuslineActive(io: Io): Promise<boolean> {
248 try {
249 return JSON.stringify((await io.settings()).statusLine ?? null).includes('statusline.mjs')
250 } catch {
251 return false
252 }
253}
254
255/**
256 * `scripts` of the package.json in `dirRel` (repo-relative, '' = root), or null when there is none / it doesn't
257 * parse. The only reason the mod reads package.json: confirming what a `npm test`-style script actually runs.
258 */
259export async function packageScripts(io: Io, root: string, dirRel = ''): Promise<Record<string, string> | null> {
260 const t = await readRel(io, root, dirRel ? `${dirRel.replace(/\/+$/, '')}/package.json` : 'package.json')
261 if (t === null) return null
262 try {
263 const s = JSON.parse(t).scripts
264 if (!s || typeof s !== 'object') return null
265 const out: Record<string, string> = {}
266 for (const [k, v] of Object.entries(s)) if (typeof v === 'string') out[k] = v
267 return out
268 } catch {
269 return null
270 }
271}
272
273/** FNV-1a 32-bit, hex — a cheap content version for dedup keys (not a security hash). */
274export function contentHash(text: string): string {
275 let h = 0x811c9dc5
276 for (let i = 0; i < text.length; i++) {
277 h ^= text.charCodeAt(i)
278 h = Math.imul(h, 0x01000193) >>> 0
279 }
280 return h.toString(16).padStart(8, '0')
281}
282
283/**
284 * The context every feature starts from: the root and the install language, or null when this folder has no
285 * .flightwake/STATE.md (flightwake isn't installed here → every feature stays silent).
286 */
287export type FwContext = { root: string; lang: Lang }
288export async function fwContext(io: Io): Promise<FwContext | null> {
289 const root = (await io.root()).replace(/\/+$/, '')
290 if (!root) return null
291 if (!(await io.exists(joinPath(root, STATE_REL)))) return null
292 return { root, lang: await detectLang(io, root) }
293}
294hooks/lib/shell.ts 242 lines1/**
2 * A deliberately small shell-command reader for F3 (test recognition) and F4 (command tripwires).
3 * It is NOT a shell parser: it splits on top-level control operators outside quotes and tokenizes each simple
4 * command. Anything it can't read plainly is flagged (`isComplex`) so callers can stay conservative.
5 */
6
7export type Segment = {
8 /** Words of one simple command, quotes removed; leading `VAR=value` assignments moved to `env`. */
9 tokens: string[]
10 env: string[]
11 /** The operator that ended this segment: '&&', '||', ';', '|', '&', '\n', or '' for the last one. */
12 op: string
13 /** A subshell boundary marker (`(` / `)`), carrying no words; F4 walks into subshells, F3 counts it as compound. */
14 group?: 'open' | 'close'
15}
16
17export type ParsedCommand = {
18 segments: Segment[]
19 /** Command substitution, backticks, subshell parens, heredocs or an unterminated quote — not plainly readable. */
20 isComplex: boolean
21}
22
23const OPS = ['&&', '||', ';;', '|&', ';', '|', '&', '\n'] as const
24
25export function parseCommand(command: string): ParsedCommand {
26 const segments: Segment[] = []
27 let isComplex = false
28 let tokens: string[] = []
29 let cur = ''
30 let hasCur = false
31 let quote: '' | "'" | '"' = ''
32 const pushWord = () => {
33 if (hasCur) tokens.push(cur)
34 cur = ''
35 hasCur = false
36 }
37 const endSegment = (op: string) => {
38 pushWord()
39 const env: string[] = []
40 while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] as string)) env.push(tokens.shift() as string)
41 if (tokens.length || env.length) segments.push({ tokens, env, op })
42 else if (segments.length && op) (segments[segments.length - 1] as Segment).op = op
43 tokens = []
44 }
45 for (let i = 0; i < command.length; i++) {
46 const c = command[i] as string
47 if (quote) {
48 if (c === quote) quote = ''
49 else if (c === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i]
50 else {
51 if (quote === '"' && (c === '`' || (c === '$' && command[i + 1] === '('))) isComplex = true
52 cur += c
53 }
54 continue
55 }
56 if (c === "'" || c === '"') {
57 quote = c
58 hasCur = true
59 continue
60 }
61 if (c === '\\' && i + 1 < command.length) {
62 if (command[i + 1] === '\n') { i++; continue } // line continuation
63 cur += command[++i]
64 hasCur = true
65 continue
66 }
67 // `$( … )` and backticks: command substitution stays inside the word (nested parens counted)
68 if (c === '$' && command[i + 1] === '(') {
69 isComplex = true
70 let depth = 0
71 for (; i < command.length; i++) {
72 const d = command[i] as string
73 cur += d
74 if (d === '(') depth++
75 else if (d === ')' && --depth === 0) break
76 }
77 hasCur = true
78 continue
79 }
80 if (c === '`') {
81 isComplex = true
82 const close = command.indexOf('`', i + 1)
83 const end = close < 0 ? command.length - 1 : close
84 cur += command.slice(i, end + 1)
85 i = end
86 hasCur = true
87 continue
88 }
89 // A bare `(` / `)` opens / closes a subshell: an explicit marker segment, so the commands inside stay visible
90 if (c === '(' || c === ')') {
91 isComplex = true
92 endSegment('')
93 segments.push({ tokens: [], env: [], op: '', group: c === '(' ? 'open' : 'close' })
94 continue
95 }
96 if (c === '<' && command[i + 1] === '<') isComplex = true
97 // `&` inside a redirection (`2>&1`, `>&2`, `<&3`, `&>file`, `&>>file`) is part of the word, not an operator
98 const isRedirAmp = c === '&' && (command[i - 1] === '>' || command[i - 1] === '<' || command[i + 1] === '>')
99 const op = isRedirAmp ? undefined : OPS.find((o) => command.startsWith(o, i))
100 if (op) {
101 endSegment(op === '|&' ? '|' : op === ';;' ? ';' : op)
102 i += op.length - 1
103 continue
104 }
105 if (c === ' ' || c === '\t') {
106 pushWord()
107 continue
108 }
109 if (c === '#' && !hasCur) {
110 // a comment runs to the end of its line only; the newline itself still ends the segment
111 while (i + 1 < command.length && command[i + 1] !== '\n') i++
112 continue
113 }
114 cur += c
115 hasCur = true
116 }
117 if (quote) isComplex = true
118 endSegment('')
119 return { segments, isComplex }
120}
121
122/** True when the tokens start with every token of `prefix` (whitespace-separated), compared exactly. */
123export function startsWithTokens(tokens: readonly string[], prefix: string): boolean {
124 const p = prefix.trim().split(/\s+/).filter(Boolean)
125 return p.length > 0 && p.length <= tokens.length && p.every((t, i) => tokens[i] === t)
126}
127
128/**
129 * A file a command writes or removes, as far as its words say. `dir` is where a relative `path` resolves: '' = the
130 * command's starting directory, 'pkg' or '/abs' after a leading `cd … &&` chain, null = no longer certain (any other cd,
131 * or a cd inside a subshell, happened earlier) — the caller must not resolve a relative path then.
132 */
133export type ShellWrite = {
134 path: string
135 via: '>' | '>>' | 'cp' | 'mv' | 'rm' | 'tee' | 'sed -i'
136 /** remove = rm targets and mv sources (gone afterwards); write = everything else. */
137 effect: 'write' | 'remove'
138 dir: string | null
139}
140
141/** A word that can only be a path as written: no expansions, no globs, not a device, not a descriptor. */
142const isPathWord = (w: string | undefined): w is string =>
143 typeof w === 'string' && w !== '' && !/[$`*?[\]{}~]/.test(w) && !w.startsWith('/dev/') && !w.startsWith('&') && !w.startsWith('-')
144
145/**
146 * Splits a command's arguments by its own option grammar: flags that take a value consume it (attached or as the next
147 * word), `--` ends options, everything else is an operand. `withValue` lists the short and long options that take a value.
148 */
149function operands(args: readonly string[], withValue: { short: string; long: readonly string[] }): { operands: string[]; values: Record<string, string[]> } {
150 const out: string[] = []
151 const values: Record<string, string[]> = {}
152 const keep = (k: string, v: string) => { (values[k] ??= []).push(v) }
153 for (let i = 0; i < args.length; i++) {
154 const a = args[i] as string
155 if (a === '--') { out.push(...args.slice(i + 1)); break }
156 if (a.startsWith('--')) {
157 const [name, eq] = a.includes('=') ? [a.slice(2, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a.slice(2), undefined]
158 if (withValue.long.includes(name)) keep(name, eq ?? (args[++i] ?? ''))
159 continue
160 }
161 if (a.startsWith('-') && a.length > 1) {
162 // a cluster of short flags; the first one that takes a value takes the rest of the word or the next word
163 for (let j = 1; j < a.length; j++) {
164 const f = a[j] as string
165 if (withValue.short.includes(f)) { keep(f, j + 1 < a.length ? a.slice(j + 1) : (args[++i] ?? '')); break }
166 }
167 continue
168 }
169 out.push(a)
170 }
171 return { operands: out, values }
172}
173
174const joinDir = (dir: string, name: string): string => `${dir.replace(/\/+$/, '')}/${name.replace(/^.*\//, '')}`
175
176/**
177 * The write targets plainly readable from a command (F3's "changed through shell commands" list): output
178 * redirections (`>f`, `>> f`, `2>f`, `&>f`; not fd duplications like `2>&1`, not /dev/*), and the operands of
179 * cp/mv (destination, or each source joined to `-t DIR`; mv also its sources), rm, tee and `sed -i`, each read with that
180 * command's option grammar so option values (sed -e/-f scripts, -S suffixes, …) never pass for paths. Words with
181 * expansions or globs are skipped. Only a candidate list — the recorder lists what git confirms as changed.
182 */
183export function shellWriteTargets(command: string): ShellWrite[] {
184 const out: ShellWrite[] = []
185 let dir: string | null = ''
186 let isChainCertain = true // every segment so far ended with && (a cd here is a certain move)
187 let depth = 0
188 for (const seg of parseCommand(command).segments) {
189 if (seg.group === 'open') { depth++; continue }
190 if (seg.group === 'close') { depth = Math.max(0, depth - 1); if (seg.op && seg.op !== '&&') isChainCertain = false; continue }
191 const add = (path: string, via: ShellWrite['via'], effect: ShellWrite['effect'] = 'write') => { if (isPathWord(path)) out.push({ path, via, effect, dir }) }
192 const words: string[] = []
193 const t = seg.tokens
194 for (let i = 0; i < t.length; i++) {
195 const tok = t[i] as string
196 const m = /^(?:\d*|&)(>>?)(.*)$/.exec(tok)
197 if (m) {
198 const via = m[1] as '>' | '>>'
199 const rest = m[2] as string
200 if (rest === '') add(t[++i] as string, via) // `> file`
201 else if (!rest.startsWith('&')) add(rest, via) // `>file`; `>&2` duplicates a descriptor
202 continue
203 }
204 if (/^\d*<.*/.test(tok)) { if (tok.replace(/^\d*</, '') === '') i++; continue } // input redirection: skip its word
205 words.push(tok)
206 }
207 const [cmd, ...args] = words
208 if (cmd === 'cd') {
209 const target = args[0] === '--' ? args[1] : args[0]
210 const isResolvable = target !== undefined && args.length <= (args[0] === '--' ? 2 : 1) && target !== '-' && isPathWord(target)
211 dir = depth === 0 && isChainCertain && seg.op === '&&' && isResolvable && dir !== null
212 ? (target.startsWith('/') ? target : dir ? `${dir}/${target}` : target)
213 : null
214 } else if (cmd === 'cp' || cmd === 'mv') {
215 const { operands: o, values } = operands(args, { short: 'tS', long: ['target-directory', 'suffix', 'backup'] })
216 const into = values.t?.[0] ?? values['target-directory']?.[0]
217 if (into !== undefined) {
218 if (isPathWord(into)) for (const src of o) { if (cmd === 'mv') add(src, 'mv', 'remove'); if (isPathWord(src)) add(joinDir(into, src), cmd) }
219 } else if (o.length >= 2) {
220 if (cmd === 'mv') for (const src of o.slice(0, -1)) add(src, 'mv', 'remove')
221 add(o[o.length - 1] as string, cmd)
222 }
223 } else if (cmd === 'rm') {
224 for (const a of operands(args, { short: '', long: [] }).operands) add(a, 'rm', 'remove')
225 } else if (cmd === 'tee') {
226 for (const a of operands(args, { short: '', long: [] }).operands) add(a, 'tee')
227 } else if (cmd === 'sed') {
228 // -i takes its suffix only when attached (GNU) — except BSD's separate empty word (`-i ''`), which is dropped here
229 const hasInPlace = args.some((a) => a === '-i' || /^-i./.test(a) || a === '--in-place' || a.startsWith('--in-place='))
230 if (hasInPlace) {
231 const cleaned = args.filter((a, i) => !(a === '' && args[i - 1] === '-i'))
232 const { operands: o, values } = operands(cleaned.map((a) => (/^-i./.test(a) ? '-i' : a)), { short: 'efl', long: ['expression', 'file', 'line-length'] })
233 // with -e/-f the script is an option value, so every operand is a file; otherwise the first operand is the script
234 const files = values.e || values.f || values.expression || values.file ? o : o.slice(1)
235 for (const f of files) add(f, 'sed -i')
236 }
237 }
238 if (seg.op !== '&&') isChainCertain = false
239 }
240 return out
241}
242hooks/lib/testcmd.ts 442 lines1/**
2 * F3's judgment of a test command (pure). Positive proof, not a blacklist (acceptance round 2, 2026-10-05):
3 *
4 * - RECORDED: any command that calls a known runner / typechecker, a package script with a test-ish name, or a command
5 * STATE declares as verification — also when it is wrapped (`sh -c`, `eval`, `xargs`, `env`, `time` …), piped or
6 * compound. Recording never depends on recognising every flag.
7 * - PROVEN (the only rows that may say pass/fail): the command is ONE direct call — no wrapper, no pipe, no compound,
8 * no substitution — of a runner from RUNNERS whose every flag sits in that runner's own safe tables; through a
9 * package script, the script body (plus any extra arguments) must pass the same test, recursively. A command STATE
10 * or package.json declares gets no exemption from the tables; its one extra allowance is running a script file
11 * directly (`bash test/smoke.sh`, `node scripts/test.js`), because then the repo itself names that file as its test.
12 * Everything else is recorded as unknown, with the reason; the caller keeps the exit code and the command.
13 */
14import { parseCommand, type ParsedCommand, type Segment } from './shell'
15
16export type RunKind = 'runner' | 'package-script' | 'state-declared' | 'typecheck'
17
18/**
19 * A value-taking flag's allowed values: any value, a pattern the whole value must match, or a closed list. A value
20 * outside it leaves the run unproven — values can stop tests from running (`go test -count=0`, `-run '^$'`).
21 */
22type ValueRule = 'any' | RegExp | readonly string[]
23const INT1 = /^[1-9]\d*$/ // a positive integer
24const INT0 = /^\d+$/
25
26type FlagTable = {
27 /** Flags that change nothing about whether tests run (`-q`, `--ci`). */
28 safe: readonly string[]
29 /** Exact `flag=value` words that are safe although the bare flag is not (`--watchAll=false`). */
30 safeExact?: readonly string[]
31 /**
32 * Flags that take a value (next word or after `=`), with the values that keep the proof. A flag that can select or
33 * configure away every test without failing (name filters in runners that exit 0 on zero tests, config files,
34 * profiles, plugins, `--require`d code) is deliberately absent: it leaves the run unproven.
35 */
36 valued: Readonly<Record<string, ValueRule>>
37 /** Flags meaning no test runs (help, version, list, collect, compile-only, watch, skip). */
38 noRun: readonly string[]
39 /** Flag prefixes that are safe whatever follows (`--allow-` for deno). */
40 safePrefixes?: readonly string[]
41}
42
43type RunnerSpec = FlagTable & {
44 name: string
45 kind: 'runner' | 'typecheck'
46 heads: readonly (readonly string[])[]
47 /** Positional words: any (paths — a path without tests fails the runner), or only these (goals/tasks). */
48 positional: true | readonly string[]
49 /** With a positional set: at least one of these must be present, or nothing test-running was asked for. */
50 needsOneOf?: readonly string[]
51 /** Maven-style `-Dkey=value`: keys that are safe / that skip tests. Any other key is unproven. */
52 defines?: { safe: readonly string[]; noRun: readonly string[] }
53 /** Flags after a `--` (cargo test's test-binary flags). Without it, a `--` makes the call unproven. */
54 afterDoubleDash?: FlagTable & { positional: true | readonly string[] }
55}
56
57const H = (...heads: string[]): string[][] => heads.map((h) => h.split(' '))
58
59// Sources: each tool's CLI reference. pytest exits 5 when no test was collected/selected, so its -k/-m filters keep
60// the proof; go test, cargo test (name filter), jest -t, mocha --grep, dotnet --filter, node --test-name-pattern … exit
61// 0 when the filter matches nothing, so those filters are left out (unknown).
62export const RUNNERS: readonly RunnerSpec[] = [
63 { name: 'pytest', kind: 'runner', heads: H('pytest', 'py.test', 'python -m pytest', 'python3 -m pytest'), positional: true,
64 safe: ['-q', '-qq', '-v', '-vv', '-vvv', '-x', '-s', '-l', '-ra', '-rA', '--lf', '--ff', '--sw', '--exitfirst', '--strict-markers', '--no-header', '--showlocals', '--last-failed', '--failed-first', '--disable-warnings'],
65 valued: { '-k': 'any', '-m': 'any', '-n': /^(\d+|auto|logical)$/, '--maxfail': INT0, '--tb': ['auto', 'long', 'short', 'line', 'native', 'no'], '--durations': INT0, '-W': 'any', '--color': ['yes', 'no', 'auto'], '--junitxml': 'any', '--basetemp': 'any', '--timeout': /^\d+(\.\d+)?$/, '--cov': 'any', '--cov-report': 'any' },
66 noRun: ['--help', '-h', '--version', '-V', '--collect-only', '--co', '--fixtures', '--markers', '--setup-plan', '--setup-only', '--fixtures-per-test'] },
67 { name: 'jest', kind: 'runner', heads: H('jest'), positional: true,
68 safe: ['--ci', '--runInBand', '-i', '--verbose', '--silent', '--coverage', '--bail', '--detectOpenHandles', '--forceExit', '--no-cache', '--colors'],
69 safeExact: ['--watchAll=false', '--watch=false'],
70 valued: { '--maxWorkers': /^\d+%?$/, '-w': /^\d+%?$/, '--testPathPattern': 'any', '--shard': /^\d+\/\d+$/, '--reporters': 'any', '--testTimeout': INT1 },
71 noRun: ['--listTests', '--help', '-h', '--version', '-v', '--showConfig', '--watch', '--watchAll', '--init', '--clearCache'] },
72 { name: 'vitest', kind: 'runner', heads: H('vitest run', 'vitest --run'), positional: true,
73 safe: ['--run', '--silent', '--coverage', '--no-color'],
74 valued: { '--reporter': 'any', '--bail': INT1, '--shard': /^\d+\/\d+$/, '--maxWorkers': /^\d+%?$/, '--pool': ['threads', 'forks', 'vmThreads', 'vmForks'], '--environment': 'any' },
75 noRun: ['--help', '-h', '--version', '-v', '--watch', '-w'] },
76 { name: 'mocha', kind: 'runner', heads: H('mocha'), positional: true,
77 safe: ['--recursive', '--bail', '-b', '--exit', '--forbid-only', '--parallel', '-p'],
78 valued: { '-R': 'any', '--reporter': 'any', '--timeout': 'any', '-t': 'any', '--spec': 'any' },
79 noRun: ['--help', '-h', '--version', '-V', '--list-files', '--list-reporters', '--list-interfaces', '--watch', '-w', '--dry-run'] },
80 { name: 'go test', kind: 'runner', heads: H('go test'), positional: true,
81 safe: ['-v', '-race', '-short', '-cover', '-failfast', '-json'],
82 valued: { '-count': INT1, '-timeout': 'any', '-p': INT1, '-coverprofile': 'any', '-covermode': ['set', 'count', 'atomic'], '-cpu': /^[\d,]+$/, '-parallel': INT1, '-bench': 'any' },
83 noRun: ['-list', '-c', '-h', '-help', '-n'] },
84 { name: 'cargo test', kind: 'runner', heads: H('cargo test'), positional: [],
85 safe: ['--release', '--all-features', '--workspace', '--all', '--lib', '--bins', '--tests', '--doc', '-q', '--quiet', '--locked', '--frozen', '--offline', '--no-default-features', '--all-targets', '--verbose', '-v'],
86 valued: { '--features': 'any', '-F': 'any', '-p': 'any', '--package': 'any', '-j': INT1, '--jobs': INT1, '--target': 'any', '--profile': 'any', '--manifest-path': 'any', '--exclude': 'any' },
87 noRun: ['--no-run', '--help', '-h', '-V', '--version'],
88 afterDoubleDash: { positional: [], safe: ['--nocapture', '--include-ignored', '--ignored', '-q', '--quiet', '--show-output'], valued: { '--test-threads': INT1, '--format': ['pretty', 'terse', 'json'], '--color': ['auto', 'always', 'never'] }, noRun: ['--list', '--help', '-h'] } },
89 { name: 'mvn', kind: 'runner', heads: H('mvn', './mvnw', 'mvnw'),
90 positional: ['clean', 'compile', 'test-compile', 'test', 'verify', 'integration-test', 'package', 'install'],
91 needsOneOf: ['test', 'verify', 'integration-test', 'package', 'install'],
92 safe: ['-V', '--show-version', '-B', '--batch-mode', '-q', '--quiet', '-e', '--errors', '-U', '--update-snapshots', '-o', '--offline', '-am', '--also-make', '-ntp', '--no-transfer-progress', '-fae', '--fail-at-end', '-ff', '--fail-fast'],
93 valued: { '-T': 'any', '--threads': 'any', '-pl': 'any', '--projects': 'any' },
94 noRun: ['-h', '--help', '-v', '--version'],
95 defines: { safe: ['test', 'it.test'], noRun: ['skipTests', 'maven.test.skip', 'skip'] } },
96 { name: 'gradle', kind: 'runner', heads: H('gradle', './gradlew', 'gradlew'),
97 positional: ['clean', 'test', 'check', 'build'], needsOneOf: ['test', 'check', 'build'],
98 safe: ['--info', '-i', '--stacktrace', '-q', '--quiet', '--no-daemon', '--build-cache', '--offline', '--continue', '--rerun-tasks'],
99 valued: { '--tests': 'any', '--console': ['plain', 'auto', 'rich', 'verbose'] },
100 noRun: ['--dry-run', '-m', '--help', '-h', '-v', '--version'] },
101 { name: 'dotnet test', kind: 'runner', heads: H('dotnet test'), positional: true,
102 safe: ['--no-build', '--no-restore', '--nologo', '--blame'],
103 valued: { '-c': 'any', '--configuration': 'any', '-v': 'any', '--verbosity': 'any', '--logger': 'any', '-l': 'any', '-f': 'any', '--framework': 'any', '-r': 'any', '--results-directory': 'any' },
104 noRun: ['--list-tests', '-t', '--help', '-h'] },
105 { name: 'node --test', kind: 'runner', heads: H('node --test'), positional: true,
106 safe: ['--experimental-test-coverage'],
107 valued: { '--test-reporter': 'any', '--test-reporter-destination': 'any', '--test-concurrency': INT1, '--test-timeout': INT1 },
108 noRun: ['--help', '-h', '--version', '-v'] },
109 { name: 'bun test', kind: 'runner', heads: H('bun test'), positional: true,
110 safe: ['--bail', '--coverage'], valued: { '--timeout': INT1, '--rerun-each': INT1 }, noRun: ['--help', '-h', '--watch'] },
111 { name: 'deno test', kind: 'runner', heads: H('deno test'), positional: true,
112 safe: ['-A', '--allow-all', '--no-check', '--parallel', '--fail-fast'], safePrefixes: ['--allow-'],
113 valued: { '--reporter': ['pretty', 'dot', 'junit', 'tap'] }, noRun: ['--help', '-h', '--watch', '--no-run'] },
114 { name: 'rspec', kind: 'runner', heads: H('rspec', 'bundle exec rspec'), positional: true,
115 safe: ['--fail-fast', '--color', '--no-color', '-b', '--backtrace'],
116 valued: { '-f': 'any', '--format': 'any', '--seed': INT0, '--order': 'any', '-o': 'any', '--out': 'any' },
117 noRun: ['--dry-run', '--help', '-h', '--version', '-v', '--init'] },
118 { name: 'phpunit', kind: 'runner', heads: H('phpunit', 'vendor/bin/phpunit', './vendor/bin/phpunit'), positional: true,
119 safe: ['--stop-on-failure', '--colors', '--testdox', '--no-coverage'], valued: {},
120 noRun: ['--list-tests', '--list-suites', '--list-groups', '--help', '-h', '--version'] },
121 { name: 'mix test', kind: 'runner', heads: H('mix test'), positional: true,
122 safe: ['--trace', '--stale', '--failed', '--cover', '--warnings-as-errors'],
123 valued: { '--seed': INT0, '--max-failures': INT1, '--timeout': INT1, '--max-cases': INT1 }, noRun: ['--help'] },
124 { name: 'make', kind: 'runner', heads: H('make test', 'make check'), positional: [],
125 safe: ['-s', '--silent', '-k', '--keep-going'], valued: { '-j': /^\d*$/ }, noRun: ['-n', '--dry-run', '--just-print', '-q', '--question', '-h', '--help'] },
126 { name: 'claude plugin test', kind: 'runner', heads: H('claude plugin test'), positional: true, safe: [], valued: {}, noRun: ['--help', '-h'] },
127 { name: 'playwright test', kind: 'runner', heads: H('playwright test'), positional: true,
128 safe: ['-x', '--headed', '--quiet', '--fully-parallel'],
129 valued: { '--workers': /^\d+%?$/, '-j': /^\d+%?$/, '--reporter': 'any', '--retries': INT0, '--timeout': INT1, '--shard': /^\d+\/\d+$/, '--max-failures': INT1 },
130 noRun: ['--list', '--help', '-h', '--ui', '--debug'] },
131 { name: 'ava', kind: 'runner', heads: H('ava'), positional: true,
132 safe: ['--verbose', '-v', '--serial', '-s', '--fail-fast', '--tap', '-t'], valued: { '--timeout': 'any', '-T': 'any', '--concurrency': INT1, '-c': INT1 },
133 noRun: ['--help', '-h', '--version', '--watch', '-w'] },
134 { name: 'tap', kind: 'runner', heads: H('tap'), positional: true,
135 safe: [], valued: { '-R': 'any', '--reporter': 'any', '-j': INT1, '--jobs': INT1, '--timeout': INT1, '-t': INT1 }, noRun: ['--help', '-h', '--version', '--watch', '-w'] },
136 { name: 'tsc', kind: 'typecheck', heads: H('tsc', 'vue-tsc'), positional: true,
137 safe: ['--noEmit', '-b', '--build', '--incremental', '--strict', '--pretty', '--listFiles'], valued: { '-p': 'any', '--project': 'any' },
138 noRun: ['--help', '-h', '--version', '-v', '--init', '--showConfig', '--watch', '-w'] },
139 { name: 'mypy', kind: 'typecheck', heads: H('mypy', 'python -m mypy', 'python3 -m mypy'), positional: true,
140 safe: ['--strict', '--ignore-missing-imports', '--no-error-summary', '--pretty'], valued: { '-p': 'any', '--package': 'any', '-m': 'any', '--module': 'any', '--python-version': 'any' },
141 noRun: ['--help', '-h', '--version', '-V'] },
142 { name: 'pyright', kind: 'typecheck', heads: H('pyright'), positional: true,
143 safe: ['--outputjson', '--warnings'], valued: { '--pythonversion': 'any', '--level': ['error', 'warning', 'information'] }, noRun: ['--help', '-h', '--version', '--watch', '-w'] },
144]
145
146/**
147 * Inline environment assignments (`X=1 pytest`) that cannot change whether or which tests run. Any other name leaves
148 * the run unproven — `PYTEST_ADDOPTS=--collect-only pytest` runs nothing and exits 0.
149 */
150export const HARMLESS_ENV = new Set(['CI', 'FORCE_COLOR', 'NO_COLOR', 'TERM', 'COLUMNS', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TZ', 'RUST_BACKTRACE', 'PYTHONUNBUFFERED', 'PYTHONDONTWRITEBYTECODE'])
151
152const valueOk = (rule: ValueRule, v: string): boolean => (rule === 'any' ? true : rule instanceof RegExp ? rule.test(v) : rule.includes(v))
153
154/** Calls that are recognised as test runs but can never be proven (bare `vitest` may start watch mode). */
155const RECOGNISE_ONLY: readonly (readonly string[])[] = H('vitest', 'playwright')
156
157/** Prefix launchers that call the next word directly (no shell): `npx [-y] vitest run`. */
158const EXEC_PREFIXES: readonly (readonly string[])[] = H('npx', 'bunx', 'pnpm exec', 'yarn exec', 'pnpm dlx')
159const EXEC_PREFIX_FLAGS = new Set(['-y', '--yes', '--no-install', '--no'])
160
161/** Wrappers: whatever they run, the exit code is not plainly the runner's (or the call is not plainly readable). */
162const WRAPPERS = new Set(['sh', 'bash', 'zsh', 'dash', 'eval', 'xargs', 'env', 'time', 'timeout', 'nice', 'nohup', 'sudo', 'exec', 'command', 'watch', 'entr', 'stdbuf'])
163
164/** Package managers and how their test-script invocations look. */
165const SCRIPT_NAME_RE = /^(test|check|verify|typecheck|lint)([:._-].*)?$/
166const PM_SAFE_FLAGS = new Set(['--silent', '-s', '--ignore-scripts', '--loglevel=silent', '-q', '--quiet'])
167const PM_WORKSPACE_FLAGS = ['--prefix', '--workspace', '-w', '--workspaces', '--filter', '-F', '-C', '--cwd', '--dir', '--recursive', '-r', '--if-present']
168/** Workspace flags that take a value as the next word (`pnpm --filter web test`). */
169const PM_VALUED_FLAGS = new Set(['--prefix', '--workspace', '-w', '--filter', '-F', '-C', '--cwd', '--dir'])
170
171/** Interpreters a declared command may run a script file with, directly (`bash test/smoke.sh`). */
172const SCRIPT_INTERPRETERS = new Set(['bash', 'sh', 'zsh', 'node', 'python', 'python3', 'ruby', 'perl'])
173
174const MAX_DEPTH = 3
175
176export type Judgment = {
177 kind: RunKind
178 isProven: boolean
179 /** Why the run is not proven (absent when proven). */
180 reason?: string
181 /** The package script body that was judged, when one was. */
182 script?: string
183}
184
185export type JudgeContext = {
186 /** package.json scripts of the directory the command runs in; null when there is none. */
187 scripts: Record<string, string> | null
188 /** Commands STATE.md declares as verification (normalised whitespace). */
189 declared: readonly string[]
190}
191
192const norm = (s: string): string => s.replace(/\s+/g, ' ').trim()
193
194/** Drops redirections (`> f`, `2>/dev/null`, `2>&1`, `&>f`, `< in`) — words, not arguments. */
195export function stripRedirects(tokens: readonly string[]): string[] {
196 const out: string[] = []
197 for (let i = 0; i < tokens.length; i++) {
198 const t = tokens[i] as string
199 const m = /^(?:\d*|&)(>>?|<)(.*)$/.exec(t)
200 if (m) {
201 if ((m[2] ?? '') === '') i++
202 continue
203 }
204 out.push(t)
205 }
206 return out
207}
208
209const startsWith = (t: readonly string[], head: readonly string[]): boolean => head.length <= t.length && head.every((h, i) => t[i] === h)
210
211/** Removes an `npx [flags]` / `pnpm exec` launcher; the launcher itself is not a wrapper (it execs the binary). */
212function unlaunch(t: readonly string[]): string[] {
213 for (const p of EXEC_PREFIXES) {
214 if (!startsWith(t, p)) continue
215 let rest = t.slice(p.length)
216 while (rest.length && EXEC_PREFIX_FLAGS.has(rest[0] as string)) rest = rest.slice(1)
217 return rest
218 }
219 return [...t]
220}
221
222function findRunner(t: readonly string[]): { spec: RunnerSpec; rest: string[] } | null {
223 let best: { spec: RunnerSpec; rest: string[]; len: number } | null = null
224 for (const spec of RUNNERS) {
225 for (const head of spec.heads) {
226 if (startsWith(t, head) && (!best || head.length > best.len)) best = { spec, rest: t.slice(head.length), len: head.length }
227 }
228 }
229 return best ? { spec: best.spec, rest: best.rest } : null
230}
231
232type FlagVerdict = { ok: true } | { ok: false; reason: 'not-a-test-run' | 'unproven-flags' | 'unproven-flag-value' | 'unproven-args' }
233
234function judgeArgs(spec: RunnerSpec, args: readonly string[]): FlagVerdict {
235 let table: FlagTable = spec
236 let positional: true | readonly string[] = spec.positional
237 const goals = new Set<string>()
238 for (let i = 0; i < args.length; i++) {
239 const tok = args[i] as string
240 if (tok === '--') {
241 if (!spec.afterDoubleDash || table === spec.afterDoubleDash) return { ok: false, reason: 'unproven-args' }
242 table = spec.afterDoubleDash
243 positional = spec.afterDoubleDash.positional
244 continue
245 }
246 if (tok.startsWith('-') && tok !== '-') {
247 if (table.safeExact?.includes(tok)) continue
248 const eq = tok.indexOf('=')
249 const name = eq >= 0 ? tok.slice(0, eq) : tok
250 if (table.noRun.includes(name)) return { ok: false, reason: 'not-a-test-run' }
251 if (spec.defines && table === spec && /^-D./.test(tok)) {
252 const key = tok.slice(2).split('=')[0] as string
253 if (spec.defines.noRun.includes(key)) return { ok: false, reason: 'not-a-test-run' }
254 if (spec.defines.safe.includes(key)) continue
255 return { ok: false, reason: 'unproven-flags' }
256 }
257 if (table.safe.includes(name) && eq < 0) continue
258 const rule = Object.prototype.hasOwnProperty.call(table.valued, name) ? table.valued[name] : undefined
259 if (rule !== undefined) {
260 const value = eq >= 0 ? tok.slice(eq + 1) : args[++i]
261 if (value === undefined || !valueOk(rule, value)) return { ok: false, reason: 'unproven-flag-value' }
262 continue
263 }
264 if (table.safePrefixes?.some((p) => tok.startsWith(p))) continue
265 return { ok: false, reason: 'unproven-flags' }
266 }
267 if (positional !== true) {
268 if (!positional.includes(tok)) return { ok: false, reason: 'unproven-args' }
269 goals.add(tok)
270 }
271 }
272 if (spec.needsOneOf && !spec.needsOneOf.some((g) => goals.has(g))) return { ok: false, reason: 'not-a-test-run' }
273 return { ok: true }
274}
275
276type PmCall = { script: string; extra: string[]; isWorkspace: boolean; hasUnknownFlag: boolean }
277
278/** `npm test`, `npm run test:unit -- -x`, `pnpm test`, `yarn lint`, `bun run check` → the script and extra args. */
279function pmCall(t: readonly string[]): PmCall | null {
280 const pm = t[0]
281 if (pm !== 'npm' && pm !== 'pnpm' && pm !== 'yarn' && pm !== 'bun') return null
282 let i = 1
283 const pre: string[] = []
284 while (i < t.length && (t[i] as string).startsWith('-')) {
285 const f = t[i++] as string
286 pre.push(f)
287 if (PM_VALUED_FLAGS.has(f) && i < t.length) pre.push(t[i++] as string) // its value, not the subcommand
288 }
289 const sub = t[i]
290 if (sub === undefined) return null
291 let script: string | null = null
292 if ((sub === 'test' || ((sub === 't' || sub === 'tst') && pm === 'npm')) && pm !== 'bun') script = 'test'
293 else if (sub === 'run' || sub === 'run-script') {
294 const name = t[i + 1]
295 if (name !== undefined && SCRIPT_NAME_RE.test(name)) { script = name; i++ }
296 } else if ((pm === 'pnpm' || pm === 'yarn') && SCRIPT_NAME_RE.test(sub)) script = sub
297 if (script === null) return null
298 const rest = t.slice(i + 1)
299 const extra: string[] = []
300 let hasUnknownFlag = false
301 for (let k = 0; k < rest.length; k++) {
302 const x = rest[k] as string
303 if (x === '--') { extra.push(...rest.slice(k + 1)); break }
304 if (PM_SAFE_FLAGS.has(x)) continue
305 extra.push(x)
306 }
307 const all = [...pre, ...rest]
308 const isWorkspace = all.some((x) => PM_WORKSPACE_FLAGS.some((f) => x === f || x.startsWith(`${f}=`)))
309 for (const x of pre) if (x.startsWith('-') && !PM_SAFE_FLAGS.has(x) && !PM_WORKSPACE_FLAGS.some((f) => x === f || x.startsWith(`${f}=`))) hasUnknownFlag = true
310 return { script, extra, isWorkspace, hasUnknownFlag }
311}
312
313/** A script file run directly by an interpreter, or by path: allowed only for a declared command. */
314function isScriptFile(t: readonly string[]): boolean {
315 if (t.length === 1) return /^\.{0,2}\/\S+$/.test(t[0] as string) || /^[\w.-]+\/[\w./-]+$/.test(t[0] as string)
316 if (t.length === 2 && SCRIPT_INTERPRETERS.has(t[0] as string)) {
317 const f = t[1] as string
318 return !f.startsWith('-') && /[./]/.test(f)
319 }
320 return false
321}
322
323/** Unwraps `sh -c "<body>"`, `env X=1 cmd`, `time cmd`, `xargs cmd` … for RECOGNITION only. */
324function unwrapForRecognition(t: readonly string[], depth: number): string[][] {
325 const w = t[0]
326 if (w === undefined || !WRAPPERS.has(w)) return [[...t]]
327 if (w === 'sh' || w === 'bash' || w === 'zsh' || w === 'dash') {
328 const ci = t.indexOf('-c')
329 if (ci >= 0 && t[ci + 1] !== undefined && depth < MAX_DEPTH) {
330 return parseCommand(t[ci + 1] as string).segments.flatMap((s) => unwrapForRecognition(stripRedirects(s.tokens), depth + 1))
331 }
332 return [t.slice(1)]
333 }
334 if (w === 'eval') return depth < MAX_DEPTH ? parseCommand(t.slice(1).join(' ')).segments.flatMap((s) => unwrapForRecognition(stripRedirects(s.tokens), depth + 1)) : []
335 let rest = t.slice(1)
336 if (w === 'timeout') rest = rest.filter((x, i) => !(i === 0 && /^\d/.test(x)))
337 while (rest.length && ((rest[0] as string).startsWith('-') || /^[A-Za-z_]\w*=/.test(rest[0] as string))) rest = rest.slice(1)
338 return unwrapForRecognition(rest, depth + 1)
339}
340
341type Recognised = { kind: RunKind; script?: string }
342
343/** Whether a simple command (already unwrapped and unlaunched) is a test-ish call; never a verdict. */
344function recogniseSimple(t0: readonly string[], ctx: JudgeContext): Recognised | null {
345 const t = unlaunch(t0)
346 if (t.length === 0) return null
347 const r = findRunner(t)
348 if (r) return { kind: r.spec.kind }
349 if (RECOGNISE_ONLY.some((h) => startsWith(t, h))) return { kind: 'runner' }
350 const pm = pmCall(t)
351 if (pm) {
352 if (pm.isWorkspace) return { kind: 'package-script' } // another package.json than the one we read: recorded, unjudgeable
353 const body = ctx.scripts?.[pm.script]
354 if (body === undefined || body.trim() === '') return null
355 if (pm.script === 'test' && /no test specified/i.test(body)) return null // npm's placeholder runs no test
356 return { kind: 'package-script', script: body }
357 }
358 return null
359}
360
361/**
362 * Proof for one simple command: its words (redirections removed) and its inline `VAR=value` assignments.
363 * `isDeclared` allows a script file.
364 */
365function prove(t0: readonly string[], env: readonly string[], ctx: JudgeContext, isDeclared: boolean, depth: number): { ok: true } | { ok: false; reason: string } {
366 if (t0.length === 0) return { ok: false, reason: 'empty' }
367 if (env.some((a) => !HARMLESS_ENV.has(a.slice(0, a.indexOf('='))))) return { ok: false, reason: 'env' }
368 // A declared script file run directly (`bash test/smoke.sh`): exactly interpreter + file, so `bash -c …` never is.
369 if (isDeclared && isScriptFile(t0)) return { ok: true }
370 if (WRAPPERS.has(t0[0] as string)) return { ok: false, reason: 'wrapper' }
371 const t = unlaunch(t0)
372 const r = findRunner(t)
373 if (r) {
374 const v = judgeArgs(r.spec, r.rest)
375 return v.ok ? v : { ok: false, reason: v.reason }
376 }
377 if (RECOGNISE_ONLY.some((h) => startsWith(t, h))) return { ok: false, reason: 'may-watch' }
378 const pm = pmCall(t)
379 if (pm) {
380 if (pm.isWorkspace) return { ok: false, reason: 'workspace-flag' }
381 if (pm.hasUnknownFlag) return { ok: false, reason: 'unproven-flags' }
382 const body = ctx.scripts?.[pm.script]
383 if (body === undefined || body.trim() === '') return { ok: false, reason: 'no-script' }
384 if (depth >= MAX_DEPTH) return { ok: false, reason: 'script-depth' }
385 const parsed = parseCommand(body.trim())
386 if (isCompound(parsed)) return { ok: false, reason: masksExit(body) ? 'script-masks-exit' : 'script-compound' }
387 const seg = parsed.segments[0] as Segment
388 return prove([...stripRedirects(seg.tokens), ...pm.extra], seg.env, ctx, true, depth + 1)
389 }
390 return { ok: false, reason: 'not-a-known-runner' }
391}
392
393const isCompound = (p: ParsedCommand): boolean => p.isComplex || p.segments.length !== 1 || p.segments.some((s) => s.op !== '')
394
395/** `… || true`, `… || exit 0`, `…; true`: the body hides its own failures. */
396export function masksExit(script: string): boolean {
397 return /\|\|\s*(true|exit\s+0|:)(\s|$|;|&|\)|")/.test(script) || /;\s*(true|exit\s+0)(\s|$|;|")/.test(script)
398}
399
400/** Backtick spans on STATE lines that talk about testing/verifying — the commands this repo declares as its checks. */
401export function declaredCommands(stateText: string): string[] {
402 const out: string[] = []
403 const talk = /test|verify|smoke|check|驗證|验证|測試|测试|檢查|检查|テスト|検証/i
404 for (const line of stateText.split(/\r?\n/)) {
405 if (!talk.test(line)) continue
406 for (const m of line.matchAll(/`([^`\n]+)`/g)) {
407 const span = norm(m[1] ?? '')
408 if (span && !span.includes('{{')) out.push(span)
409 }
410 }
411 return out
412}
413
414/**
415 * Judges the part of a Bash command after any leading `cd <dir> &&` (the caller handles the cd and the cwd).
416 * null → not a test-ish command: not recorded.
417 */
418export function judge(segments: readonly Segment[], isComplex: boolean, ctx: JudgeContext): Judgment | null {
419 const parsed: ParsedCommand = { segments: [...segments], isComplex }
420 const text = norm(segments.map((s) => [...s.env, ...s.tokens].join(' ') + (s.op && s.op !== '\n' ? ` ${s.op}` : '')).join(' '))
421 const isDeclaredText = ctx.declared.includes(text) || ctx.declared.includes(norm(segments.map((s) => s.tokens.join(' ')).join(' ')))
422
423 // Recognition: any segment, unwrapped, that is a test-ish call (or the whole text is declared).
424 let rec: Recognised | null = isDeclaredText ? { kind: 'state-declared' } : null
425 if (rec === null) {
426 for (const seg of segments) {
427 for (const t of unwrapForRecognition(stripRedirects(seg.tokens), 0)) {
428 rec = recogniseSimple(t, ctx)
429 if (rec) break
430 }
431 if (rec) break
432 }
433 }
434 if (rec === null) return null
435
436 const base: Judgment = rec.script !== undefined ? { kind: rec.kind, isProven: false, script: rec.script } : { kind: rec.kind, isProven: false }
437 if (isCompound(parsed)) return { ...base, reason: 'compound' }
438 const seg = segments[0] as Segment
439 const p = prove(stripRedirects(seg.tokens), seg.env, ctx, isDeclaredText, 0)
440 return p.ok ? { ...base, isProven: true } : { ...base, reason: p.reason }
441}
442hooks/lib/glob.ts 46 lines1/**
2 * Repo-relative glob matching for TRAPS `paths:` and roles `deny-write:` (no regex is ever taken from user data).
3 * Syntax: `**` any number of path segments, `*` within one segment, `?` one character; everything else literal.
4 * A pattern with no `/` matches the basename at any depth (gitignore-style: `*.sql` hits `db/x.sql`);
5 * a trailing `/` means "this directory and everything under it". Paths and patterns use `/`; a leading `./` or
6 * `/` on the pattern is dropped (patterns are always repo-relative).
7 */
8const cache = new Map<string, RegExp>()
9
10function compile(pattern: string): RegExp {
11 const hit = cache.get(pattern)
12 if (hit) return hit
13 let p = pattern.trim().replace(/^\.\//, '').replace(/^\/+/, '')
14 if (p.endsWith('/')) p += '**'
15 const anyDepth = !p.includes('/')
16 let re = ''
17 for (let i = 0; i < p.length; i++) {
18 const c = p[i] as string
19 if (c === '*') {
20 if (p[i + 1] === '*') {
21 // `**/` → zero or more segments; a bare `**` → anything
22 if (p[i + 2] === '/') {
23 re += '(?:[^/]+/)*'
24 i += 2
25 } else {
26 re += '.*'
27 i += 1
28 }
29 } else re += '[^/]*'
30 } else if (c === '?') re += '[^/]'
31 else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
32 }
33 const out = new RegExp(anyDepth ? `^(?:.*/)?${re}$` : `^${re}$`)
34 cache.set(pattern, out)
35 return out
36}
37
38/** Whether repo-relative `path` matches `pattern`. An empty pattern never matches. */
39export function matchGlob(pattern: string, path: string): boolean {
40 if (!pattern.trim()) return false
41 return compile(pattern).test(path.replace(/^\.\//, ''))
42}
43
44export const matchAny = (patterns: readonly string[], path: string): string | null =>
45 patterns.find((p) => matchGlob(p, path)) ?? null
46hooks/lib/traps.ts 63 lines1/**
2 * TRAPS.md parsing. An entry is a frontmatter block (`---` … `---`) followed by its body, up to the next entry's
3 * opening `---`. Entries without frontmatter (pre-OKF registries) are not entries here — they simply never match.
4 *
5 * Matching fields (both optional; an entry with neither never takes part, and never errors):
6 * paths: ["hooks/**", "*.sql"] repo-relative globs (lib/glob.ts), checked against written files
7 * commands: ["git push", "npm publish"] command-token prefixes, checked against Bash commands
8 * No regex anywhere. Reading rules: missing `status` → active; missing `confidence` → unknown (presented as a lead);
9 * an entry whose text still holds a `{{…}}` placeholder is template residue and skipped.
10 */
11import { contentHash, parseFlatYaml, parseInlineList } from './core'
12
13export type Confidence = 'confirmed' | 'probable' | 'suspected' | 'unknown'
14
15export type TrapEntry = {
16 name: string
17 status: 'active' | 'superseded' | string
18 confidence: Confidence
19 type: string
20 paths: string[]
21 commands: string[]
22 /** The body's bold-labelled lines in order (template: symptom, root cause, fix/workaround, evidence). */
23 labelled: string[]
24 body: string
25 /** Content version of the whole entry (frontmatter + body): dedup key part, so an edited entry can hint again. */
26 version: string
27}
28
29const ENTRY_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n([\s\S]*?)(?=^---[ \t]*\r?\n[A-Za-z_][\w-]*:|(?![\s\S]))/gm
30
31export function parseTraps(text: string): TrapEntry[] {
32 const out: TrapEntry[] = []
33 // Drop HTML comments first: the shipped header documents the format with example frontmatter-like lines.
34 // Repeat until nothing changes: one pass can join the text around a removed comment into a new `<!--`.
35 let clean = text
36 for (let prev = ''; prev !== clean;) { prev = clean; clean = clean.replace(/<!--[\s\S]*?-->/g, '') }
37 let m: RegExpExecArray | null
38 while ((m = ENTRY_RE.exec(clean)) !== null) {
39 const fmText = m[1] ?? ''
40 const body = (m[2] ?? '').trim()
41 const fm = parseFlatYaml(fmText)
42 const name = fm.name ?? ''
43 if (!name) continue
44 if (/\{\{[^}\n]*\}\}/.test(fmText) || /\{\{[^}\n]*\}\}/.test(body)) continue
45 const conf = (fm.confidence ?? '').toLowerCase()
46 out.push({
47 name,
48 status: (fm.status ?? 'active').toLowerCase() || 'active',
49 confidence: conf === 'confirmed' || conf === 'probable' || conf === 'suspected' ? conf : 'unknown',
50 type: fm.type ?? '',
51 paths: parseInlineList(fm.paths),
52 commands: parseInlineList(fm.commands),
53 labelled: body.split(/\r?\n/).filter((l) => /^\*\*[^*]+\*\*/.test(l.trim())).map((l) => l.trim()),
54 body,
55 version: contentHash(`${fmText}\n${body}`),
56 })
57 }
58 return out
59}
60
61export const isActive = (e: TrapEntry): boolean => e.status !== 'superseded'
62export const hasMatchers = (e: TrapEntry): boolean => e.paths.length > 0 || e.commands.length > 0
63