SLOPSHOPPER

src

Academic Writer hooks — session dashboard, session logging (TypeScript/ESM)

panecommandtimeragents
v?no licenseupdated 2026-10-26yodem/academic-writer/src
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · src
│ ┃ Academic Writer ✕ › fix the failing auth test and add an audit log call │ ┃ No Academic Writer run yet. Start one with │ ┃ /academic-writer:write. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /activity │ ⎿ src: The activity pane is open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Academic Writer
No Academic Writer run yet. Start one with /academic-writer:write.
README

Academic Writer

Academic Writer is a plugin (an add-on) for Claude Code that helps humanities researchers write scholarly articles in Hebrew or English. You talk with Claude in plain language. The plugin takes you from a rough topic to a footnoted Word file: it reads the sources you choose, helps you settle a thesis and an outline, drafts in your own style, checks every citation against your sources, and then folds your Word comments back into the text.

See it in action on the Academic Writer site (English and Hebrew). A fuller, step-by-step version of this page is the Academic Writer guide, which you can read online with no install. It also ships with the plugin as an offline guide: after installing, type /academic-writer:intro to open it in your browser.


What you need

WhatWhyCost
Claude CodeThe program the plugin runs inside. Install steps are below.A Claude plan that includes Claude Code (Pro, Max, Team or Enterprise; the free plan does not)
A Gemini API keyAn API key is a long password that lets the plugin use Google's Gemini AI. Setup walks you through getting one from your own Google account. See Your Gemini key.Free tier available
Microsoft Word (or a compatible program)To read the finished .docx and mark it up with comments and tracked changes. LibreOffice opens the file too, but Word handles comments and tracked changes best.Your existing copy

Also useful:

  • Python 3, a free program the plugin uses to read PDFs and build Word files. Some Macs and most Linux computers already have it; if yours does not, setup shows you, click by click, how to install it from python.org. Setup also asks before installing the small add-ons it needs.
  • 5 to 10 of your published articles (PDF or Word), so the plugin can learn how you write.
  • Your source texts as PDFs or Word files. The optional Trove library (an online bookshelf for your sources; install Trove, docs) lets the plugin search and quote them page by page.

Install

You install once. About 15 minutes if you have never used Claude Code. Each step says what you should see.

Step A. Get Claude Code and sign in (skip if you already use it).

  1. You need a Claude plan that includes Claude Code (Pro, Max, Team or Enterprise). Subscribe or sign in at claude.ai.
  2. Open the Terminal app (a window where you type commands instead of clicking). Mac: press Cmd+Space, type Terminal, press Enter. Windows: Start menu, type PowerShell, press Enter. Linux: Ctrl+Alt+T on most systems.
  3. Paste this line and press Enter (Windows PowerShell: irm https://claude.ai/install.ps1 | iex). It shows no progress for a minute; that is normal.
   curl -fsSL https://claude.ai/install.sh | bash
  1. Make a folder for your research (for example Academic inside Documents). In Terminal type cd (with a space), drag that folder into the window, press Enter, type claude and press Enter.
  2. The first time, your web browser opens so you can sign in. Sign in, then return to the Terminal window. You now see Claude Code's prompt box (where you type to Claude).

If Terminal says command not found: claude, close it and open a new Terminal window, then try step 4 again. Prefer an app? Anthropic also offers a Claude desktop app with Claude Code; the website steps for it are at yodem.github.io/academic-writer.

Step B. Add the plugin. In the prompt box, type these two lines, one at a time, pressing Enter after each:

/plugin marketplace add yodem/academic-writer
/plugin install academic-writer@yodem

The first line tells Claude Code where to find the plugin (a marketplace is a list of plugins; this one is called yodem). The second installs it. Each prints a short confirmation. If the first one says it cannot find the marketplace, check the line is typed exactly as above and try again; if it still fails, copy the message and paste it to Claude. (A line starting with / is typed in Claude Code's prompt box, not in Terminal.)

Step C. Restart. Type /exit and press Enter, then type claude again in the same folder, so the new commands appear.

Step D. Check it. Type /plugin, open the Installed tab with the arrow keys, and look for academic-writer. If it is missing, repeat Step B. Then go to First steps below.

If you prefer to add the plugin from Terminal instead of the prompt box, the same two steps are:

claude plugin marketplace add yodem/academic-writer
claude plugin install academic-writer@yodem

First steps

Run these inside Claude Code, started in your research folder (not your main home folder).

  1. /academic-writer:intro opens the full guide in your web browser.
  2. /academic-writer:setup is a short guided conversation, one question at a time, about ten minutes. It asks your field, article language and citation style; checks the tools the plugin needs and asks your permission before installing anything; walks you through getting and safely storing a free Gemini key, then tests it; and writes one short sample paragraph. After you store the key, setup asks you to quit Claude Code completely, open it again in the same folder, type /academic-writer:setup and choose Check my Gemini key; your answers are saved.
  3. /academic-writer:init (recommended) builds your full profile. First copy 5 to 10 of your published articles (PDF or Word) into the past-articles folder inside your research folder (setup created it). Init reads them, shows you what it learned about your style so you can correct it, and helps you connect your source library.
  4. /academic-writer:write starts your first article.

Running setup or init again later is safe: they update your profile and never erase it.


How writing an article works

/academic-writer:write has a conversation part, where you make every decision about the argument, and a drafting part the plugin runs on its own.

  1. Your voice. Before anything else, write checks that your voice profile (the examples and writing habits drafts should follow) exists. If it does not, it asks whether to build it first with /academic-writer:voice (about 10 minutes, using 3 or more of your past articles) or to write in a neutral scholarly voice for now.
  2. Topic and language. Say what you want to write about and in which language.
  3. Sources. You pick the sources for this article from the ones you made available.
  4. Your thesis. You state the thesis in your own words; the plugin records it exactly as you wrote it.
  5. Verified evidence. The plugin reads the chosen sources and keeps only passages whose quotations match the source word for word. These are the only passages the draft may quote or cite.
  6. Outline. Together you shape the sections. You approve each section's claim; nothing is drafted until every claim is approved.
  7. Drafting. Gemini drafts the article from the approved outline, section by section, in your style, with the introduction and conclusion written last. Automatic checks reject any draft that cites a source not on your list, quotes text that is not in the verified passages, or breaks your style rules.
  8. Citation check. Claude then checks every paragraph's citations against your sources. A paragraph that fails is rewritten. If it still fails after the allowed number of attempts, it is marked [NEEDS REVIEW] and named in the final report so you know exactly what to check.
  9. Word file. You get a .docx with real Word footnotes. Claude tells you where it saved it.
  10. Your comments. Open it in Word, add comments or tracked changes wherever you want something changed, save and close.
  11. Revise. Run /academic-writer:revise. It reads your comments and tracked changes, groups them, asks you to confirm each group, updates the text and makes a new Word file. With your agreement, it also learns from your rewrites so later drafts sound more like you. Repeat steps 10 and 11 as often as you like.

Before you submit, run /academic-writer:proofread for a typo, language and editorial pass (the original is never changed; you get a corrected copy, a report and an editorial memo), and /academic-writer:review for a scorecard of the article's structure, argument and citations.


Commands

Type any of these in Claude Code. You can also describe what you want in your own words ("proofread my article"), and Claude picks the right command for most requests. Setup, init and voice run only when you type them, because they change your settings.

CommandWhat it does
/academic-writer:introOpen the full guide in your web browser.
/academic-writer:setupFirst-run setup: tools, Gemini key, a sample paragraph. Asks before installing anything.
/academic-writer:initFull onboarding: your profile, your writing style from past articles, your source library.
/academic-writer:writeWrite a new article, step by step, ending with a Word file.
/academic-writer:reviseRead your Word comments and tracked changes and update the article.
/academic-writer:editRevise an article you already drafted: several passages, tone, structure, argument order or evidence.
/academic-writer:edit-sectionQuick revision of one section: rewrite, expand, shorten, fix citations or style.
/academic-writer:proofreadProofread a finished article (Markdown or Word): a corrected copy, a report and an editorial memo.
/academic-writer:reviewScore a finished article on six dimensions and show concrete findings.
/academic-writer:researchInvestigate a topic or check a claim against your sources; only word-for-word verified quotations come back.
/academic-writer:ingestUpload a single article or chapter to your source library, keeping its printed page numbers and adding a citation guide.
/academic-writer:ingest-bookUpload a whole book (multi-volume, scanned or EPUB) with a clickable table of contents that jumps to the right page.
/academic-writer:ideateTurn a vague topic into a sharp research question; saves a research brief that write reads.
/academic-writer:ocrTurn a scanned PDF or photographed page (or a folder of them) into plain text, with Gemini, Claude, Codex or local Tesseract, your choice.
/academic-writer:library-evalCompare Trove and PageIndex on your own article: you judge anonymous A and B answers, then see which library was which.
/academic-writer:voiceBuild or update your voice profile: exemplar paragraphs you choose and your writing habits.
/academic-writer:healthCheck that everything is ready, with a plain-language fix for anything missing.
/academic-writer:helpA short list of every command and the three steps to get started.

A few more things the plugin can do have no command of their own. Ask for them in plain words:

  • Learn from new papers or your corrections: "I added new papers to past-articles, update my voice."
  • Follow-on materials: "Make a conference talk outline (or a journal abstract, or a chapter proposal) from this article."
  • Change your field of study: "Change my field in my profile to ..."
  • Connect or disconnect Trove or NotebookLM: "Turn on Trove in my profile."

Choosing a library (optional)

Your books are read from Trove unless you choose otherwise. Nothing here changes how the plugin works for you today, and you can skip this whole section.

What the choices are. Each article project can read its books from Trove (the default), from PageIndex, or from both, where one library answers and the other is tested quietly in the background without ever changing, slowing or failing your result. PageIndex is a second library that keeps a short outline of every book, with a few lines on each section, so Claude can go straight to the likely page. You choose per article, and you can change your mind with /academic-writer:update-tools.

Two ways to get PageIndex. Setup, init and update-tools offer both, in plain words, and ask before doing anything:

  1. The plugin's own helper. The plugin sets up a small helper in its own data folder (not in your system). The basic helper is about 100 MB and needs Python 3.9 or newer; a larger one (about 500 MB, Python 3.10 or newer and, on a Mac, the pango library) can also prepare a book that has no table of contents. You can remove it again. The steps are below.
  2. Your own PageIndex. If you already run PageIndex yourself, for example on a server, you type its command into the plugin's pageindex_command setting (/plugin, then Installed, then academic-writer, then Configure). A command you set yourself always wins over the built-in helper.

Preparing your books. A book must be prepared once before PageIndex can read it. When you add a book with /academic-writer:ingest or /academic-writer:ingest-book, the plugin writes it to Trove first, checks it, and then offers to prepare it for PageIndex too, with the same table of contents and the same page numbers, so a citation means the same page in both. You can also add a reading to a book you already have, and prepare the books already in Trove (a "backfill") in one go. For each preparation the plugin tells you in advance how much text would be sent to Google and asks you; the free choice sends nothing (see Privacy).

What is measured. Every lookup and every citation check is logged on your computer in .academic-helper/retrieval.jsonl, with the time it took, an estimate of how many tokens of text it returned, and the citation checker's verdict. aw library report turns that into a comparison on your real articles: speed, failures, how often the two libraries return the same page, the citation checker's pass rate, tokens, and the cost of any preparation and evaluation runs. Token figures from the log are estimates, not a bill.

Judge it yourself: a blind evaluation. /academic-writer:library-eval picks claims from one of your drafts, has a separate Claude (or Codex) look up the supporting pages in each library, and shows you the two answers side by side as "A" and "B" in random order on a private page that works offline, in Hebrew and English. You mark each pair better A, better B, same or neither, with a note. Only after you finish does the plugin reveal which library was which, with win rates, a confidence range, time, cost and how often each found the page the citation checker had confirmed. With too few pairs it tells you it is only a first look.

Install steps for the built-in helper. Run these in Claude Code, started in your research folder, and answer the questions it asks:

  1. Type /academic-writer:update-tools (or /academic-writer:setup, the first time) and say you want PageIndex. Among the choices it offers, pick Set it up on this computer.
  2. Read the plan it shows (which size, how much disk space, where it goes) and say yes if you want it. Nothing is installed before that.
  3. If it says your Python is too old or pango is missing for the larger helper, you can accept the basic helper or follow the short steps it gives for your system. It never installs Python for you.
  4. Type /academic-writer:health to confirm it is ready. It shows which helper is in use.

Full details, commands, the report explained line by line and the known limits are in docs/library-routes.md.


Your Gemini key

What it is. Gemini is Google's AI model. In this version, the plugin's own drafting tool (aw) sends every drafting, evidence-extraction (used by write and research), synthesis, editing, proofreading, OCR and voice-analysis step to Gemini, while Claude runs the conversation and checks every citation.

Is it optional? Setup lets you answer "Not now", and nothing is then sent to Google. Planning and review still work, but the steps listed above need the key, so in practice you need one to write, research, proofread or OCR. Setup tells you this when you choose.

Cost. Google's free tier is enough for normal use, and creating a key normally needs no credit card. Get one at aistudio.google.com/apikey; it starts with AIza.

Where to keep it. Never paste the key into the Claude chat: the conversation is saved, so a key there could be read later. Use one of these:

  1. Plugin settings (recommended). In Claude Code, type /plugin, open Installed, choose academic-writer, then Configure, and paste the key into google_api_key. Claude Code keeps it in your computer's keychain (on Linux, a protected credentials file), not in a plain settings file. At the start of each session the plugin passes it on to its tools, but only in a research folder that already has a profile (.academic-helper/profile.md), which setup creates.
  2. An environment variable. Set GOOGLE_API_KEY in your shell profile (macOS/Linux) or as a user environment variable (Windows). This also works for other tools on your computer. /academic-writer:setup shows the exact steps for your system.

Either way, the key is picked up only when a new Claude Code session starts. The plugin reads nothing else: a .academic-helper/secrets.json file is not used.


Privacy

  • Anthropic (Claude). Your conversation with Claude, and the text Claude reads to help you (your drafts, the source passages it checks), go to Anthropic, as with any use of Claude Code.
  • Google (Gemini). With a Gemini key, the text being drafted, edited, proofread or summarised, the source passages it relies on, and any scanned pages you ask it to read are sent to Google. On Google's free tier, Google may use what you send to improve its products. Check Google's current terms, and consider a paid key for unpublished work you consider sensitive.
  • Google (Gemini) and PageIndex. If you prepare books for PageIndex and choose Gemini summaries, the text of each section of the book is sent to Google, with your Gemini key, so Gemini can write the short outline. The plugin tells you how much text, and asks first, every time. The free option (--summaries hints, offered alongside) sends nothing: it reuses the hints already written for Trove. The same Google terms as above apply. Nothing is ever sent to PageIndex Cloud: the plugin has no code for it and needs no PageIndex account.
  • Anthropic (or OpenAI) and the blind evaluation. /academic-writer:library-eval runs a separate Claude Code (or Codex, if you choose it) to find supporting pages. The claims from your draft and the page text it reads go to Anthropic (or OpenAI), on your own plan, after you have seen an estimate and said yes. The quiet background test of a second library uses no AI model at all.
  • Trove (optional). Only the sources you choose to upload are stored there. If you use Trove, your voice profile is also synced there so it follows you between computers.
  • Jev (advanced, off by default). Only if you enter the optional jev_api_key setting, the question and context at a few decision points are also sent to the Jev service. In this version its answers are only logged and never change your results. Most users should leave it empty.
  • Your keys. Saved through the plugin settings, the key is kept in your keychain. The plugin never prints it or writes it into your research folder.

What stays on your computer, and where:

WhereWhat
.academic-helper/ in your research folderThis project's working files: your profile (profile.md), the source list, the outline, drafts, verified evidence, a log of each run, a log of library lookups (retrieval.jsonl) and, if you run an evaluation, its files (library-eval/), including the sealed key that says which side was which.
past-articles/, ocr-output/ and your .docx files, in your research folderYour published articles, text from scanned pages, and the finished articles.
The plugin's data folder, ~/.claude/plugins/data/ (the folder whose name starts with academic-writer)Your voice data, shared by all your projects, a small log of plugin mistakes you flagged during revise, and, only if you installed it, the built-in PageIndex helper and the books prepared for it (the pageindex folder).

Folders whose names start with a dot are hidden by default. On a Mac, press Cmd+Shift+. in Finder; on Windows, choose View, then Show, then Hidden items in File Explorer.

Uninstalling deletes the plugin's data folder, including your voice data, unless you keep it:

claude plugin uninstall academic-writer@yodem --keep-data

Your research folders, drafts and Word files are never touched by uninstalling.


Troubleshooting

Start with /academic-writer:health. It checks your profile, key, tools and sources, and tells you in plain words what to fix. Its report contains no keys, so you can share it when asking for help.

  • The /academic-writer: commands do not appear. Quit Claude Code completely and open it again. Then type /plugin and check that academic-writer is listed and enabled. If not, repeat the two install lines.
  • "missing Google API key" or "API key not valid". Run /academic-writer:setup again and choose to check the Gemini key. After saving a key, start a new Claude Code session in your research folder.
  • "No profile found". Make sure you started Claude Code inside your research folder. In a new folder, run /academic-writer:setup first.
  • Write asks about your voice profile. Choose "Build my voice first" and run /academic-writer:voice, or continue without it and build it later.
  • The Word file is not created, or a "missing Python package" message. Run /academic-writer:setup; it offers to install the add-ons. To do it yourself: python3 -m pip install --user python-docx lxml pdfplumber pymupdf jsonschema.
  • Proofreading a Word file fails. The proofread command converts .docx files with pandoc, a free document converter, which setup does not install. Install it from pandoc.org/installing.html, or proofread a Markdown copy.
  • The PageIndex helper will not install, or aw library status says PageIndex is not reachable. Run /academic-writer:health; it names the cause (Python older than 3.10 for the larger helper, a missing pango library on a Mac, no network, or an old command in pageindex_command). The basic helper works with Python 3.9. Trove keeps working either way.
  • Hebrew looks reversed in the Claude Code window. That is only the display; the Word file is laid out right to left.

Still stuck? Open an issue at github.com/yodem/academic-writer/issues and include the output of /academic-writer:health.


Updating

In a terminal (or through /plugin in Claude Code), then restart Claude Code:

claude plugin marketplace update yodem
claude plugin update academic-writer@yodem

Refreshing the marketplace alone does not update an installed plugin; run both lines. Updating keeps your profile, drafts and voice data. See CHANGELOG.md for what changed.


For contributors

Source lives in src/. Never edit plugins/ directly: npm run build generates it from src/ and manifests/, and the output is committed.

npm run build                           # assemble plugins/academic-writer/ from src/
npm run build:hooks                     # rebuild only the TypeScript hooks
npm run typecheck                       # str
Source 3 files
mod/register.tsx 125 lines
1// academic-writer activity pane: never name a local `h` in this file. Every JSX tag compiles to a call of
2// `h`, the JSX factory global, and a local `h` breaks the first draw.
3//
4// Read-only: it reads runs/<id>/activity.jsonl with `$.fs` and never writes or submits a prompt. No model call.
5// Which run counts as active is the hooks' rule (active.ts, pinned to findActiveRun by active.test.ts); the age limit
6// comes from the plugin's thresholds.json. A finished run is not shown.
7// The activity file is re-read on a 2 s clock tick only while a run is active; engine events (turn.complete,
8// agent.spawn) are only triggers to re-read.
9import type { EngineInterface as Engine, Register } from 'claude-code'
10import { activeRunMaxAgeMinutes, pickActiveRun, type RunFile } from './active.ts'
11import { type PaneView, parseEvents, viewOf } from './model.ts'
12
13const PANE_ID = 'aw-activity'
14// Not 'watch': that name belongs to the plugin's /academic-writer:watch skill, and the engine refuses a second owner.
15const COMMAND = 'activity'
16const TICK_MS = 2000
17
18type Timer = { cancel: () => void }
19
20const STATE_GLYPH: Record<string, string> = {
21  done: '✓', current: '◐', todo: '·', skipped: '–', drafted: '✎', passed: '✓', rejected: '✗', redrafted: '⟳',
22}
23
24function clock(totalS: number): string {
25  const m = Math.floor(totalS / 60)
26  const s = totalS % 60
27  return `${m}:${String(s).padStart(2, '0')}`
28}
29
30let paneEnabled = true
31let view: PaneView | undefined
32let active = false
33let timer: Timer | undefined
34
35async function refresh($: Engine): Promise<void> {
36  try {
37    const cwd = await $.session.cwd()
38    const root = `${cwd}/.academic-helper`
39    if (!(await $.fs.exists(`${root}/profile.md`)) || !(await $.fs.exists(`${root}/runs`))) { setActive($, false, undefined); return }
40    const now = await $.clock.now()
41    const maxAge = activeRunMaxAgeMinutes(await $.fs.read(`${$.plugin.root}/thresholds.json`).catch(() => undefined))
42    const runs: RunFile[] = []
43    for (const entry of await $.fs.list(`${root}/runs`)) {
44      if (entry.kind !== 'dir') continue
45      const file = `${root}/runs/${entry.name}/activity.jsonl`
46      try {
47        const stat = await $.fs.stat(file, { resolve: false })
48        runs.push({ id: entry.name, text: await $.fs.read(file), mtimeMs: stat.mtimeMs ?? 0 })
49      } catch {
50        // a run folder without an activity file
51      }
52    }
53    const pick = pickActiveRun(runs, now, maxAge)
54    setActive($, pick !== undefined, pick ? viewOf(parseEvents(pick.text), new Date(now)) : undefined)
55  } catch {
56    // unreadable: keep the last view, never crash the session
57  }
58}
59
60function setActive($: Engine, running: boolean, next: PaneView | undefined): void {
61  view = next
62  if (running && !timer) timer = $.clock.every(TICK_MS, () => { void refresh($) })
63  if (!running && timer) { timer.cancel(); timer = undefined }
64  active = running
65  $.ui.invalidate('ui.render')
66}
67
68export const register: Register = (on, options) => {
69  paneEnabled = options['activity_pane'] !== false
70  view = undefined
71  active = false
72  timer = undefined
73
74  on('session.start', async ($, e, next) => {
75    // a refused name must not take the pane down with it
76    await $.command.register({ name: COMMAND, description: 'Show the live Academic Writer activity pane' }).catch(() => undefined)
77    if (paneEnabled && (await $.fs.exists(`${e.cwd}/.academic-helper/profile.md`).catch(() => false))) {
78      // unasked: the engine places it only in a wide terminal (144+ columns)
79      void $.ui.open({ id: PANE_ID, title: 'Academic Writer' })
80      void refresh($)
81    }
82    return next(e)
83  })
84
85  on('command.run', { command: COMMAND }, async ($) => {
86    const opened = await $.ui.open({ id: PANE_ID, title: 'Academic Writer' })
87    await refresh($)
88    return { text: opened.isPlaced ? 'The activity pane is open.' : 'The terminal is too narrow for the pane; run `aw activity show --latest` for a status line.' }
89  })
90
91  on('turn.complete', async ($, e, next) => {
92    void refresh($)
93    return next(e)
94  })
95
96  on('agent.spawn', async ($, e, next) => {
97    void refresh($)
98    return next(e)
99  })
100
101  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
102    const { Box, Text } = $.ui.resolve(e)
103    if (!view || view.steps.length === 0) {
104      return <Text dimColor>No Academic Writer run yet. Start one with /academic-writer:write.</Text>
105    }
106    const v = view
107    return (
108      <Box flexDirection="column">
109        <Text bold>{v.flow}  {clock(v.elapsedS)}</Text>
110        {v.steps.map((s) => (
111          <Text dimColor={s.state === 'todo' || s.state === 'skipped'}>{STATE_GLYPH[s.state]} {s.id}{s.state === 'current' ? ' ...' : ''}</Text>
112        ))}
113        {v.units.length > 0 && <Text dimColor>sections</Text>}
114        {v.units.map((u) => (
115          <Text>{STATE_GLYPH[u.state]} {u.id}</Text>
116        ))}
117        {v.agent && <Text>agent: {v.agent}</Text>}
118        {v.rejections.map((r) => (
119          <Text>✗ {r.unit}: {r.reason}</Text>
120        ))}
121      </Box>
122    )
123  })
124}
125
mod/active.ts 49 lines
1// Pure active-run rule for the activity pane. It must give the same answer as `findActiveRun` in
2// src/hooks/src/lib/activity.ts (the hooks are bundled and ship without their sources, so the mod cannot import
3// it); active.test.ts runs both over the same run folders and fails if they drift. No imports, no Node, no Claude Code.
4
5/** Fallback when thresholds.json is unreadable; same value as the hooks' default and `activity.activeRunMaxAgeMinutes`. */
6export const DEFAULT_ACTIVE_RUN_MAX_AGE_MINUTES = 360
7
8export type RunFile = { id: string; text: string; mtimeMs: number }
9
10/** `activity.activeRunMaxAgeMinutes` from the text of the plugin's thresholds.json. */
11export function activeRunMaxAgeMinutes(thresholdsText: string | undefined): number {
12  if (thresholdsText === undefined) return DEFAULT_ACTIVE_RUN_MAX_AGE_MINUTES
13  try {
14    const parsed = JSON.parse(thresholdsText) as { activity?: { activeRunMaxAgeMinutes?: unknown } }
15    const value = parsed.activity?.activeRunMaxAgeMinutes
16    return typeof value === 'number' && value > 0 ? value : DEFAULT_ACTIVE_RUN_MAX_AGE_MINUTES
17  } catch {
18    return DEFAULT_ACTIVE_RUN_MAX_AGE_MINUTES
19  }
20}
21
22function startedAndEnded(text: string): { started: boolean; ended: boolean } {
23  let started = false
24  let ended = false
25  for (const line of text.split('\n')) {
26    if (!line.trim()) continue
27    try {
28      const row = JSON.parse(line) as { kind?: unknown }
29      if (row.kind === 'run_start') started = true
30      if (row.kind === 'run_end') ended = true
31    } catch {
32      // a torn line is skipped
33    }
34  }
35  return { started, ended }
36}
37
38/** The newest run with a run_start, no run_end and a recent mtime; undefined when none is active. */
39export function pickActiveRun(runs: RunFile[], now: number, maxAgeMinutes: number): RunFile | undefined {
40  let best: RunFile | undefined
41  for (const run of runs) {
42    if (now - run.mtimeMs > maxAgeMinutes * 60_000) continue
43    const { started, ended } = startedAndEnded(run.text)
44    if (!started || ended) continue
45    if (!best || run.mtimeMs > best.mtimeMs) best = run
46  }
47  return best
48}
49
mod/model.ts 110 lines
1// Pure model of one run's activity.jsonl: no Claude Code imports, so it is unit-testable.
2// Mirrors src/schemas/activity-event.schema.json. Steps and verdicts only; never article text.
3
4export type ActivityEvent = {
5  v: 1;
6  ts: string;
7  run: string;
8  flow: string;
9  kind: string;
10  step?: string;
11  unit?: string;
12  paragraph?: number;
13  verdict?: string;
14  reason?: string;
15  agent?: string;
16  exit?: string;
17};
18
19export type StepState = 'done' | 'current' | 'todo' | 'skipped';
20export type UnitState = 'drafted' | 'passed' | 'rejected' | 'redrafted';
21export type PaneView = {
22  flow: string;
23  steps: { id: string; state: StepState }[];
24  units: { id: string; state: UnitState }[];
25  agent: string | null;
26  elapsedS: number;
27  rejections: { unit: string; reason: string }[];
28};
29
30// Kept equal to FLOW_STEPS in src/cli/src/activity.ts and src/scripts/activity.py (src/cli/tests/activity-fixes.test.ts checks it).
31export const FLOW_STEPS: Record<string, string[]> = {
32  write: ['outline', 'draft', 'audit', 'redraft', 'synthesize', 'word'],
33  edit: ['edit'],
34  'edit-section': ['edit'],
35  proofread: ['find', 'verify', 'memo', 'apply'],
36  revise: ['read', 'update-draft', 'update-stores'],
37};
38
39/** Every line that parses as an event with a string `kind` and `ts`; torn or foreign lines are skipped. */
40export function parseEvents(text: string): ActivityEvent[] {
41  const events: ActivityEvent[] = [];
42  for (const line of text.split('\n')) {
43    if (!line.trim()) continue;
44    try {
45      const row: unknown = JSON.parse(line);
46      if (!row || typeof row !== 'object') continue;
47      const rec = row as Record<string, unknown>;
48      if (typeof rec['kind'] !== 'string' || typeof rec['ts'] !== 'string' || typeof rec['flow'] !== 'string') continue;
49      events.push(rec as unknown as ActivityEvent);
50    } catch {
51      // a half-written last line (run killed mid-write) is skipped
52    }
53  }
54  return events;
55}
56
57function unitOf(ev: ActivityEvent): string | undefined {
58  if (ev.unit) return ev.unit;
59  return typeof ev.paragraph === 'number' ? `¶${ev.paragraph}` : undefined;
60}
61
62export function viewOf(events: ActivityEvent[], now: Date): PaneView {
63  const first = events[0];
64  if (!first) return { flow: 'write', steps: [], units: [], agent: null, elapsedS: 0, rejections: [] };
65  const flow = events.find((e) => e.kind === 'run_start')?.flow ?? first.flow;
66  const ids = FLOW_STEPS[flow] ?? FLOW_STEPS['write'] ?? [];
67  const started = new Set<string>();
68  const ended = new Set<string>();
69  const units = new Map<string, UnitState>();
70  const redone = new Set<string>();
71  const rejections: { unit: string; reason: string }[] = [];
72  let agent: string | null = null;
73  let finished = false;
74  let endTs: string | null = null;
75  let endExit = 'OK';
76  for (const ev of events) {
77    if (ev.kind === 'step_start' && ev.step) started.add(ev.step);
78    else if (ev.kind === 'step_end' && ev.step) ended.add(ev.step);
79    else if (ev.kind === 'unit_drafted' && ev.unit) units.set(ev.unit, redone.has(ev.unit) ? 'redrafted' : 'drafted');
80    else if (ev.kind === 'redraft' && ev.unit) { redone.add(ev.unit); units.set(ev.unit, 'redrafted'); }
81    else if (ev.kind === 'audit_verdict') {
82      const unit = unitOf(ev);
83      // A rejected paragraph keeps its unit rejected until the unit is drafted again; a verdict that could not be read changes nothing.
84      if (unit && ev.verdict === 'PASS') { if (units.get(unit) !== 'rejected') units.set(unit, 'passed'); }
85      else if (unit && (ev.verdict === 'FAIL' || ev.verdict === 'PARTIAL')) {
86        units.set(unit, 'rejected');
87        rejections.push({ unit, reason: ev.reason ?? '' });
88      }
89    } else if (ev.kind === 'subagent_start') agent = ev.agent ?? null;
90    else if (ev.kind === 'subagent_stop') agent = null;
91    else if (ev.kind === 'run_end') { finished = true; endTs = ev.ts; endExit = ev.exit ?? 'OK'; }
92  }
93  if (finished) agent = null;
94  const lastSeen = Math.max(-1, ...ids.map((id, i) => (started.has(id) || ended.has(id) ? i : -1)));
95  const steps = ids.map((id, i) => {
96    let state: StepState;
97    if (ended.has(id) || (finished && started.has(id))) state = 'done';
98    else if (started.has(id) && !finished) state = 'current';
99    // `outline` is finished by the skill before the first `aw` event of a write run.
100    else if (id === 'outline' && lastSeen > i) state = 'done';
101    // A step that never happened in a run that finished well (no redraft needed) is skipped, not still to do.
102    else state = finished && endExit === 'OK' ? 'skipped' : 'todo';
103    return { id, state };
104  });
105  const t0 = Date.parse(events.find((e) => e.kind === 'run_start')?.ts ?? first.ts);
106  const t1 = endTs ? Date.parse(endTs) : now.getTime();
107  const elapsedS = Number.isFinite(t0) && Number.isFinite(t1) ? Math.max(0, Math.round((t1 - t0) / 1000)) : 0;
108  return { flow, steps, units: [...units].map(([id, state]) => ({ id, state })), agent, elapsedS, rejections };
109}
110