SLOPSHOPPER

flightwake-mod

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…

newbandguardcommandtoaststatus
★ 10v0.1.0MITupdated 2026-10-05kaiwutech-TW/flightwake/mods/flightwake
A shopper browsing a rack in a slop shop
README

<!-- Primary edition (English). Translations: README.zh-TW.md / README.zh-CN.md / README.ja.md — editing any edition obligates syncing the others. -->

flightwake ✈️

Records are the contrail your work naturally leaves behind — not a flight plan you must file before takeoff.

npm OpenSSF Scorecard

🌐 English · 繁體中文 · 简体中文 · 日本語

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 repo: /fw-coldstart reads STATE + the latest record and reports a safe takeover in ~30 seconds

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.

Install

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:

LanguageFresh installAlready installed in another language
Englishnpx flightwake init --statuslinenpx flightwake init --lang=en --force --statusline
繁體中文npx flightwake init --lang=zh-TW --statuslinenpx flightwake init --lang=zh-TW --force --statusline
简体中文npx flightwake init --lang=zh-CN --statuslinenpx flightwake init --lang=zh-CN --force --statusline
日本語npx flightwake init --lang=ja --statuslinenpx 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.

How to use

Right after the first install

  1. Open an agent session in the repo and run /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)
  2. git add .flightwake .claude CLAUDE.md && git commit
  3. Every session after that follows the daily loop below

The daily loop

You (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.

The only thing you need to watch

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.

See what it actually looks like

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.

Stage-by-stage playbook

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)

Why this project exists

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:

  1. Sessions die; context is finite. When work spans sessions, memory resets to zero; without records, every takeover is a git archaeology dig — a strong model just digs faster, it doesn't get to skip the dig.
  2. Git records the what, not the why. Commits tell you what changed, never "why the other path wasn't taken" or "the root cause of that trap" — which happen to be the two most expensive pieces of information for the next session (or the next agent).
  3. Discipline drifts in long sessions. "Reported done before the tests ran", "touched prod without leaving verification evidence" — these slides have nothing to do with model intelligence and need guards outside the model.
  4. Agents don't share state. Claude, Codex, Gemini, and human teammates each see their own world; state only becomes everyone's once it's in git.

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:

  1. Dashcam: decisions, discoveries, verification evidence, recorded after the fact (records/, DECISIONS.md, TRAPS.md)
  2. Warning lights: hard guards independent of model strength (tests green before "done", prod changes must leave verification evidence, destructive operations need confirmation first)
  3. Road signs: any session can die; the next session reads STATE.md and takes over safely within 2 minutes

The 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.

Core principle: records follow work — they don't lead it

GSD is stage-driven (research→plan→execute→verify gates); flightwake is trigger-driven (events create obligations):

Trigger eventObligationTool
Starting to touch a repoRead STATE + the latest record first/fw-coldstart
Making a decision that closes off other optionsOne line into DECISIONS (append-only, with the why)write directly
Hitting a non-obvious trapOne entry into TRAPS/fw-trap
Touching schema / touching prod / ~3+ commitsWrap up with a record/fw-record
Work will span sessionsWrite handoff/CONTEXT before stopping (not before starting)/fw-handoff
Session about to closeUpdate STATE's position and next-step entry pointpart 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).

File layout (after installing into a target repo)

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).

Advanced install

--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.

Migrating from GSD

Wrap up your current milestone first, then:

  1. npx flightwake init — coexists with .planning/; nothing is deleted
  2. Tell your agent: "This repo is switching from GSD to flightwake. Read .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."
  3. Remove (or comment out) GSD's own instruction block from CLAUDE.md, so the two rulebooks don't compete for the model's obedience

Status line (optional)

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.

CI-side wrap-up check (optional)

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.

Boundaries with neighboring systems

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.

Security

  • Zero dependencies, no network, no install scripts: the installer only copies files; the hook only uses git (no shell) for read-only queries.
  • Fixed write scope: 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 de
Source 14 files
hooks/register.ts 38 lines
1/**
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}
38
hooks/features/state-inject.ts 162 lines
1/**
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}
162
hooks/features/band.ts 217 lines
1/**
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}
217
hooks/features/recorder.ts 521 lines
1/**
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}
521
hooks/features/tripwire.ts 239 lines
1/**
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}
239
hooks/features/role-guard.ts 289 lines
1/**
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}
289
hooks/features/status.ts 190 lines
1/**
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}
190
hooks/lib/core.ts 294 lines
1/**
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}
294
hooks/lib/shell.ts 242 lines
1/**
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}
242
hooks/lib/testcmd.ts 442 lines
1/**
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}
442
hooks/lib/glob.ts 46 lines
1/**
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
46
hooks/lib/traps.ts 63 lines
1/**
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