SLOPSHOPPER

project-pm

One PM session per project: a live board of every Claude Code session (waiting on you, stopped mid-plan, CI still running) and a ledger of the work you asked…

panebandguardcommandtoastprompt
v0.5.1MITupdated 2026-10-21AudiChang-hub/claude-project-pm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-pm
│ ┃ app · Board ✕ › fix the failing auth test and add an audit log call │ ┃ Could not read the session list: undefined │ ┃ is not an object (evaluating ⏺ Read(src/auth.ts) │ ┃ 'result.content.map') ⎿ 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 │ │ › /pm │ ⎿ project-pm: This session's id is not available (perhaps it is no │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · app · Board
Could not read the session list: undefined is not an object (evaluating 'result.content.map')
README

Project PM for Claude Code

English | 繁體中文

Run many Claude Code sessions in parallel without losing track of any work.

CI License: MIT

Project PM gives each project one PM session. You talk to the PM; the PM keeps a live board of every other session in that project and a ledger of everything you asked for, and it will not call the work done while a session is waiting on you, stopped halfway, or still waiting on CI.

<img src="docs/images/board-hero.png" alt="The Project PM board for a demo project: 44% of ledger items done, one session waiting on an answer, one with failed CI, one stopped with four steps left, one merged but not pushed." width="100%">

<sub>Demo data. The board speaks English or Traditional Chinese, following your system language (see Language).</sub>

Why

With five or ten sessions open on one project, things slip:

  • A session asked you a question an hour ago and has been sitting there since.
  • A session stopped with three steps of its plan left, and nothing tells you.
  • A session said "CI is running, I'll deploy once it passes", and then nobody came back to check.
  • Every session ticks its own checklist off when its turn ends, so "all green" in the sidebar does not mean the thing you asked for is done.

Project PM reads every session's task list, final recap and open question, checks CI on GitHub, and puts what needs you at the top of one board.

Quick start

Requires the Claude Code desktop app (Code tab); see Requirements.

  1. Install the plugin. In a Claude Code prompt:
   /plugin install project-pm --marketplace AudiChang-hub/claude-project-pm

In the desktop app, where /plugin is not available: click + next to the prompt box → Plugins → Add marketplace, enter AudiChang-hub/claude-project-pm, then Add plugin.

Or from a shell:

   claude plugin install project-pm --marketplace AudiChang-hub/claude-project-pm
  1. Open a new session in your project (sessions opened before the install do not load it) and type:
   /pm

That session becomes the project's PM, opens the board, and reports what is waiting on you.

  1. From then on, talk to the PM: "Is anything slipping?", "Hand the CSV export to the product-list session.", "Which sessions are waiting on me?"

Features

One board per project. A pane that lists the project's sessions, most urgent first, each with a status mark, a step-by-step progress bar, the step it is on, and its latest recap or question.

StatusShown when
Waiting on you (待回覆)The session ended its turn with a question for you.
CI failed (CI 失敗)Its last words left CI running, and GitHub reports that run failed.
Unfinished (未完成)It stopped with steps of its task list still open, and nothing it waits on is still running.
Not released (待發布)Work is left to deliver, judged facts first (see How delivery is judged below): a commit or push git shows is left, a release the session declared pending, or, with neither to go on, words that say the work is still to come. A fully ticked task list does not override it (wrap_up ticks every step).
Waiting on CI (等待 CI)Its last words say CI has not finished, and GitHub has not said it passed.
Running (執行中)The session is working right now.
In the background (背景執行)A command it started in the background (run_in_background, or moved there) has not reported back yet, for up to 6 hours; or, for a session without that record, it said work goes on in the background (for up to 45 minutes). Open steps on its task list do not make it Unfinished meanwhile.
Ended (已結束) / Idle (長期閒置)Nothing left; idle for more than 3 days. Hidden behind a toggle.

At the top: a completion ring and three counters (running, waiting on you, not finished: unfinished, waiting on CI, CI failed or not released). Switch between this project and all projects.

A ledger of what you asked for. Every request you give the PM is recorded as T-01, T-02, … with the session that owns it. Project progress is counted from the ledger, not from the sessions' own checklists. An owner working from another project's folder (a session fixing a shared library, say) is still shown with its title and status; the item is flagged only when its owner is archived or gone.

CI is not done until GitHub says so. When a session's latest words say CI is still running, or that it will deploy once checks pass, the session is held at Waiting on CI, however long ago it stopped. Quoted words (a status named 「等待 CI」) and sentences about a rule or an example do not count. The PM then runs gh in that session's folder: the runs and commits the words name, nearest their own CI words first, a commit only when it is in that repository (a hash quoted from another project or an example is skipped), and when none of them answers, the folder's HEAD (only if it was committed before the words). A pass clears the status, a failure turns it into CI failed. Answers are cached: a pass for good, a failure for 5 minutes, a running or unknown result for 1 minute. Without gh, or outside GitHub, the words decide.

The ledger will not close unfinished work. Marking an item done is refused while its owner is running, waiting on you, unfinished, waiting on CI, failed CI, not released or still working in the background, unless you confirm it explicitly and the PM records why in a note.

Recaps that admit work is left. A recap that says something is "not yet deployed", "not committed", "still needs…" (in English or Chinese) is flagged for follow-up. A Chinese double negative such as 沒有任何未提交的修改 ("there are no uncommitted changes") is not.

Only the latest turn counts. A recap belongs to the turn that wrote it; a later turn that ends without its own recap clears it. If a session's record has missed later turns, its transcript's last message is used instead.

A PM that behaves like one. The PM reads the board before every report, hands implementation to other sessions instead of editing code itself, relays sub-sessions' questions to you word for word, asks before archiving anything, and will not say "all done" while anything needs attention. It gets a notification when a session starts waiting on you, fails CI, or stops with steps left.

Housekeeping. Archive a finished session from its card (hover to reveal, then confirm; the confirm warns about open steps, an open PR or a pin). In a project with no PM yet, a band above the prompt offers to start a PM session or make the current one the PM.

<img src="docs/images/board-dark.png" alt="The full board in dark mode: ledger items and seven active sessions, including one not released, one running and one in the background." width="420">

How delivery is judged

Facts first, words last:

  1. Git, for commit and push. For each session of its own project the PM reads the session's folder, read-only and without fetching. A folder with no uncommitted changes has nothing left to commit. The commit the report names as its own (commit 是 3fa1c2e, 提交為 3fa1c2e, amended into 3fa1c2e), or a clean folder's HEAD, on a remote branch has nothing left to push; a named commit on no remote branch has its push left, whatever the words say. Git only settles or confirms what it can check: a folder shared with other sessions may hold their changes, so a dirty folder alone adds nothing.
  2. The session's declaration, for a release. In a project with a PM, the other sessions are asked to end their recap (or last answer) with one line: 交付:不需要部署, 交付:已部署(…) or 交付:待部署(等…) (Delivery: not needed, deployed (…), pending (…)). That line decides the release. Sessions of projects without a PM are told nothing.
  3. Words, only where neither answers. A report leaves work only when it says the work is still to come: not yet (還沒, 尚未, 未, not yet), what comes next (之後再部署, I'll push after review), a wait (等 PM 放行後部署), or work still sitting here (只在本機,沒有 push). A plain "not done" (沒有打標籤也沒有部署, didn't push) does not count: as often as not it means this time needed none. Rules, examples, quoted words, someone else's work and descriptions of the board are set aside first.

What this lets through: a plain "沒有 commit" of changes that really are uncommitted, or "沒有部署" of a release that really is due, from a session that neither declared it nor named a commit of its own. A declaration, a commit named in the report, or the open steps of its task list bring those back.

Usage

CommandWhat it does
/pmMakes this session the project's PM and opens the board.
/pm offSteps down as PM. The ledger stays with the project for the next PM.
/boardOpens or closes the board in any session.
/pm lang en · /pm lang zh-TW · /pm lang autoSwitches the language of the board, the PM and its tools at once, and saves it as the language option. auto follows the system.

The PM has two tools of its own, which you do not call directly:

  • mcp__project-pm__board: the project status report (what needs attention, the ledger, every session). Pass scope: "all" for every project, include_done: true to list finished sessions and items too.
  • mcp__project-pm__ledger: add, update (owner, status open / done / dropped, note) and list the work items.

A project is the folder a session works in; a git worktree under .claude/worktrees/ or .worktrees/ counts as its repository.

Requirements

  • Claude Code desktop app, Code tab. The PM uses the app's built-in session tools (ccd_session_mgmt to list, read and archive sessions; ccd_session to start the PM session), and sessions report their task lists through the app's update_plan and wrap_up. Sessions that keep their tasks with TodoWrite instead are tracked as well.
  • A Claude Code version that supports function-hook mods. Tested with 2.1.299.
  • Optional: the GitHub CLI (gh), signed in, to check CI on GitHub. Without it, CI status is judged from what the sessions said.
  • In an organization that manages Claude Code, the PM's role instructions (a prompt.compose hook) only apply when the organization lists this plugin in its managed settings. The board and the ledger work either way.

Language

The board, the band above the prompt, the commands' replies, the notifications, the PM's instructions and its tools speak English or Traditional Chinese.

  • By default it follows the system language (on Windows, the display language): any Chinese locale shows Traditional Chinese, everything else English. Simplified Chinese locales show Traditional Chinese too; run /pm lang en if you would rather read English.
  • To choose one, set the plugin's language option to auto, en or zh-TW (the project-pm.language row in /config, stored under pluginConfigs in settings.json), or type /pm lang en, /pm lang zh-TW or /pm lang auto in any session. The command takes effect at once and saves the option.
  • Reading what sessions report works in both languages whichever one the board speaks, so sessions can work in English or Chinese.

Every string lives in hooks/messages.ts, one table per language.

Data and privacy

  • Each session writes only its own record: ~/.claude/project-pm/sessions/<session id>.json (its task list, last recap, open question, latest prompt and last answer, clipped, and whether you left its board open).
  • Each project has one file for its PM and ledger: ~/.claude/project-pm/projects/<project>-<hash>.json.
  • A board keeps its last round (the sessions listed, their last messages, what git and GitHub said) in ~/.claude/project-pm/rounds/<session id>.json, and ~/.claude/project-pm/transcripts.json says which session each transcript belongs to, so a resumed session draws its board at once.
  • The mod itself sends nothing anywhere. Listing, reading and archiving sessions go through the desktop app's built-in tools. To check CI and delivery it runs git and gh in the session's folder, read-only (git status without refreshing the index, branch -r --contains, rev-parse; no fetch), so gh contacts GitHub with your own login, as it would if you ran it yourself.
  • In a project with a PM, the other sessions get one short instruction each turn: end the recap with a delivery line (see How delivery is judged). Sessions of other projects get nothing.
  • Sessions opened before the install keep no record; the board shows their last message, read from the transcript.
  • When the engine drops the PM's board and ledger tools (after /reload-plugins, or when the mod's folder changed and a command of it such as /board reloads it), they come back on the PM's next turn (your next message or a notification) or within 30 seconds while the PM is idle; no need to restart the session.
  • The board stays as you leave it: /board or /pm opens it, /board or the pane's close closes it. When the desktop app stops an idle session to free memory and resumes it later, or the engine drops the pane, a board you left open comes back as the session resumes, drawn from its last round.
  • The desktop app stops every session idle for a minute or more whenever free memory falls under 5%, counting Windows' commit limit (memory plus page file) as well as free memory. A board that keeps disappearing usually means the commit limit runs short: a larger page file, or fewer heavy processes, keeps sessions running. The mod itself adds about 40 MB of commit per session; each PM asks git and GitHub only about its own project (every project while the board shows them all), asks again about CI GitHub knows nothing of every ten minutes, and redraws the board only when something changed or while it is on screen.

Development

claude plugin validate .
claude plugin test .

The tests (tests/pm.test.ts) run against Claude Code's plugin test host with an in-memory desktop app, disk and gh; they need no login or API key. CI runs the same two commands on every push and pull request.

docs/demo/board.html draws the README images from made-up data; see docs/demo/README.md.

Changes are listed in CHANGELOG.md.

License

MIT

Source 6 files
hooks/register.tsx 314 lines
1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Me } from '../types'
5
6import { applyPlan, clip, finishedTasks, planFromTodos } from './sessions'
7import {
8  BOARD_TOOL,
9  CLAIM,
10  LEDGER_TOOL,
11  MGMT,
12  PANE,
13  POLL_MS,
14  ROLE,
15  board,
16  changeMine,
17  claim,
18  closesBoard,
19  deliveryAsk,
20  ensureBoard,
21  ensureTools,
22  getMine,
23  identify,
24  ledger,
25  loadRound,
26  openBoard,
27  reason,
28  refresh,
29  refreshSoon,
30  registerTools,
31  release,
32  rememberBoard,
33  str,
34  textOf,
35} from './actions'
36import { getLang, resolveLang, setLang, systemLocale, t } from './messages'
37import { renderBand, renderPane } from './view'
38
39// The engine reads each module's state refs from its own source: these name the same values as the other modules'.
40const me = atom({ plugin: 'project-pm', key: 'me' } as const, null as Me | null)
41
42/** Whether this copy of the module has done its start-up: a reload starts a fresh copy. */
43let isStarted = false
44
45/** The `/config` row of the `language` option (plugin.json `userConfig`): auto, en or zh-TW. */
46const LANGUAGE_ROW = 'project-pm.language'
47
48async function registerCommands($: EngineInterface) {
49  await $.command.register({ name: 'pm', description: t('command.pm') })
50  await $.command.register({ name: 'board', description: t('command.board') })
51}
52
53/**
54 * `/pm lang <en|zh-TW|auto>`: speaks it from now on, and saves it as the `language` option, the same
55 * `/config` row and settings.json `pluginConfigs` entry a person sets by hand (the engine then reloads
56 * the module with it). Words the engine already holds, the commands' and the tools' descriptions, are
57 * given again in the new language.
58 */
59async function changeLanguage($: EngineInterface, wanted: string): Promise<string> {
60  const value = /^(en|english|英)/i.test(wanted) ? 'en' : /^(zh|中|繁|tw)/i.test(wanted) ? 'zh-TW' : /^(auto|system|系統|自動)/i.test(wanted) ? 'auto' : null
61  if (!value) return t('lang.usage', { lang: getLang() })
62  setLang(resolveLang(value, systemLocale()))
63  await registerCommands($)
64  if ((await read($, me))?.isPm) await registerTools($)
65  let failure: string | null = null
66  try {
67    const result = await $.config.set({ key: LANGUAGE_ROW, value })
68    if ('deny' in result && result.deny) failure = result.deny
69  } catch (err) {
70    failure = reason(err)
71  }
72  if (failure !== null) return t('lang.notSaved', { lang: getLang(), reason: failure })
73
74  return value === 'auto' ? t('lang.setAuto', { lang: getLang() }) : t('lang.set', { lang: getLang() })
75}
76
77/** Brings the board back when it should be up (ensureBoard): a failure is logged, never in the way. */
78async function keepBoard($: EngineInterface, why: 'start' | 'prompt' | 'poll') {
79  try {
80    await ensureBoard($, why)
81  } catch (err) {
82    $.ui.log(`project-pm: ${reason(err)}`)
83  }
84}
85
86/**
87 * The commands, the PM's tools and the polling timer. `session.start` does it at the session's
88 * start; /reload-plugins loads a fresh copy without one (and the engine drops the old copy's
89 * tools), so the first hook to run in that copy does it instead. Tools the engine drops later
90 * (a change to the mod's folder reloads it before a command of it runs) come back by ensureTools.
91 */
92async function start($: EngineInterface) {
93  if (isStarted) return
94  isStarted = true
95  await registerCommands($)
96  try {
97    const self = await identify($)
98    if (self?.isPm) await registerTools($)
99    // A new process draws the board from the last round at once; its own first round follows.
100    await loadRound($)
101  } catch (err) {
102    $.ui.log(`project-pm: ${reason(err)}`)
103  }
104  await keepBoard($, 'start')
105  // A round still running when its copy of the module goes (a reload) has nowhere left to report.
106  $.clock.every(POLL_MS, () => void keepBoard($, 'poll')
107    .then(() => refresh($))
108    .catch(() => undefined))
109  refreshSoon($, 300)
110}
111
112// ── Hooks ──
113
114export const register: Register = (on, options) => {
115  // The `language` option when it names one, else the system's (messages.ts): read once per load,
116  // and a change to the option reloads the module.
117  setLang(resolveLang(options.language, systemLocale()))
118
119  on('session.start', async ($, e, next) => {
120    await start($)
121    // A new process has no panes: the board comes back if it was left open (a start in the same copy, too).
122    await keepBoard($, 'start')
123    // A new process: background commands of the one before it ended with it.
124    await changeMine($, t => (t.background?.length ? { ...t, background: [] } : t))
125
126    return next(e)
127  })
128
129  on('prompt.submit', async ($, e, next) => {
130    await start($)
131    await ensureTools($)
132    await keepBoard($, 'prompt')
133    if (e.text.includes(CLAIM)) {
134      try {
135        await claim($)
136      } catch (err) {
137        $.ui.log(`project-pm: ${reason(err)}`)
138      }
139    }
140    // A new turn starts: last turn's recap and question no longer stand (a background wake-up included).
141    if (!e.command) await changeMine($, t => ({ ...t, ask: null, recap: null, ...(e.isMeta ? {} : { lastPrompt: clip(e.text, 400) }) }))
142    // A background command's notification: it has finished, one way or another.
143    const finished = finishedTasks(e.text)
144    if (finished.length) await changeMine($, t => ({ ...t, background: (t.background ?? []).filter(b => !finished.includes(b.id)) }))
145    refreshSoon($, 500)
146
147    return next(e)
148  }).catch(($, e, next) => next(e))
149
150  on('tool.call', { tool: 'mcp__ccd_turn__update_plan' }, async ($, e, next) => {
151    const result = await next(e)
152    if (e.agentId === undefined && result.deny === undefined && result.isError !== true) {
153      const input = e as unknown as Record<string, unknown>
154      const now = await $.clock.now()
155      await changeMine($, t => ({ ...t, plan: applyPlan(t.plan, input), planAt: now }))
156    }
157
158    return result
159  }).catch(($, e, next) => next(e))
160
161  // A command run in the background (asked for, or moved there) runs on after the turn: the session waits on it.
162  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
163    const result = await next(e)
164    // Bash and PowerShell both answer `backgroundTaskId` when the command runs on in the background.
165    const id = (result.result as { backgroundTaskId?: string } | undefined)?.backgroundTaskId
166    if (e.agentId === undefined && id) {
167      const now = await $.clock.now()
168      const label = clip(e.description || e.command, 120)
169      await changeMine($, t => ({ ...t, background: [...(t.background ?? []).filter(b => b.id !== id), { id, label, at: now }] }))
170    }
171
172    return result
173  }).catch(($, e, next) => next(e))
174
175  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
176    const result = await next(e)
177    const input = e as unknown as Record<string, unknown>
178    const id = str(input.task_id) ?? str(input.shell_id)
179    if (id && result.deny === undefined) await changeMine($, t => ({ ...t, background: (t.background ?? []).filter(b => b.id !== id) }))
180
181    return result
182  }).catch(($, e, next) => next(e))
183
184  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
185    const result = await next(e)
186    if (e.agentId === undefined && result.deny === undefined && result.isError !== true) {
187      const now = await $.clock.now()
188      await changeMine($, t => ({ ...t, plan: planFromTodos(e.todos), planAt: now }))
189    }
190
191    return result
192  }).catch(($, e, next) => next(e))
193
194  on('tool.call', { tool: 'mcp__ccd_turn__wrap_up' }, async ($, e, next) => {
195    const result = await next(e)
196    if (e.agentId === undefined && result.deny === undefined && result.isError !== true) {
197      const input = e as unknown as Record<string, unknown>
198      const now = await $.clock.now()
199      await changeMine($, t => ({
200        ...t,
201        recap: str(input.recap) ?? null,
202        ask: str(input.ask) ?? null,
203        recapAt: now,
204        plan: t.plan.map(s => ({ ...s, status: 'completed' as const })),
205        planAt: now,
206      }))
207    }
208
209    return result
210  }).catch(($, e, next) => next(e))
211
212  on('turn.complete', async ($, e, next) => {
213    if (e.agentId === undefined) {
214      const now = await $.clock.now()
215      // A turn that ended without its own wrap_up leaves no recap: the one there is an older turn's.
216      await changeMine($, t => ({
217        ...t,
218        lastAnswer: clip(e.answer, 800),
219        turnAt: now,
220        ...((t.recapAt ?? 0) > t.turnAt ? {} : { recap: null, ask: null }),
221      }))
222      // The title is often given after the first turn.
223      if (getMine() && !getMine()?.title) {
224        try {
225          const result = await $.mcp.call(MGMT, 'get_session', { session_id: 'self' })
226          const o = JSON.parse(textOf(result)) as Record<string, unknown>
227          if (typeof o.title === 'string') await changeMine($, t => ({ ...t, title: o.title as string }))
228        } catch {
229          // The title is a nicety.
230        }
231      }
232      refreshSoon($, 500)
233    }
234
235    return next(e)
236  })
237
238  on('tool.call', { tool: BOARD_TOOL }, async ($, e) => ({ result: await board($, e as unknown as Record<string, unknown>) })).catch(() => ({
239    deny: t('command.denyBoard'),
240  }))
241  on('tool.call', { tool: LEDGER_TOOL }, async ($, e) => {
242    const text = await ledger($, e as unknown as Record<string, unknown>)
243    refreshSoon($)
244
245    return { result: text }
246  }).catch(() => ({ deny: t('command.denyLedger') }))
247
248  on('prompt.compose', async ($, e, next) => {
249    await start($)
250    await ensureTools($)
251    const composed = await next(e)
252    const self = await read($, me)
253    if (!self?.isPm) {
254      // A session of a project with a PM declares its delivery at its end; other projects' sessions hear nothing.
255      const ask = await deliveryAsk($)
256
257      return ask ? { sections: [...composed.sections, { id: 'project-pm:delivery', text: ask, scope: 'session' as const }] } : composed
258    }
259
260    return { sections: [...composed.sections, { id: 'project-pm:role', text: ROLE(self), scope: 'session' as const }] }
261  })
262
263  on('command.run', { command: 'pm' }, async ($, e) => {
264    await start($)
265    if (/^\s*(off|卸任|取消)\s*$/i.test(e.args)) return { text: await release($) }
266    const lang = /^\s*(?:lang|language|語言)(?:\s+(\S+))?\s*$/i.exec(e.args)
267    if (lang) return { text: await changeLanguage($, lang[1] ?? '') }
268    const text = await claim($)
269    const self = await read($, me)
270    if (!self?.isPm) return { text }
271
272    return {
273      text,
274      context: [
275        `${ROLE(self)}\n\n${t('role.justClaimed', { board: BOARD_TOOL })}`,
276      ],
277    }
278  })
279
280  // /board turns the board on and off, and the board stays as the person leaves it. A board left undrawn (opened
281  // unasked on a narrow window) is not on yet: /board places it.
282  on('command.run', { command: 'board' }, async $ => {
283    await start($)
284    if ((await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)) {
285      // The close itself keeps it closed (ui.close below).
286      await $.ui.close({ id: PANE })
287
288      return { text: t('command.boardClosed') }
289    }
290    const self = await identify($)
291    await rememberBoard($, true)
292    await openBoard($, self)
293    // As the poll's round: nowhere to report once this copy of the module is gone.
294    void refresh($, true).catch(() => undefined)
295
296    return { text: t('command.boardOpened') }
297  })
298
299  // The person's close (the pane's close mark or key, the app closing it) and /board's own: the board stays closed
300  // until /board. An unload (the engine dropping a pane nothing draws) never reaches the hooks, and a process that
301  // ends closes nothing: those come back (ensureBoard).
302  on('ui.close', async ($, e, next) => {
303    const result = await next(e)
304    if (closesBoard(e)) await rememberBoard($, false)
305
306    return result
307  })
308
309  // ── Drawing ──
310
311  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => renderBand($, e, () => next(e)))
312  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => renderPane($, e))
313}
314
hooks/sessions.ts 1033 lines
1// Reading sessions into a project's board: who is working, who waits on you,
2// who stopped with steps left, and what the PM was asked to keep track of.
3
4import type { BackgroundTask, CiCheck, LastWord, LedgerItem, ProjectFile, PushCheck, Session, Step, Track } from '../types'
5import { t } from './messages'
6
7export type Status = 'running' | 'asking' | 'failed' | 'open' | 'unreleased' | 'ci' | 'waiting' | 'done' | 'stale'
8
9/** How each status looks: `color` is a theme key (drawn right in light and dark), `hex` the same hue for SVG marks. */
10const LOOK: Record<Status, { color: string; hex: string; rank: number }> = {
11  asking: { color: 'warning', hex: '#f59e0b', rank: 0 },
12  failed: { color: 'error', hex: '#ef4444', rank: 1 },
13  open: { color: 'claude', hex: '#d97757', rank: 2 },
14  unreleased: { color: 'planMode', hex: '#0d9488', rank: 3 },
15  ci: { color: 'permission', hex: '#8b5cf6', rank: 4 },
16  running: { color: 'success', hex: '#22c55e', rank: 5 },
17  waiting: { color: 'suggestion', hex: '#3b82f6', rank: 6 },
18  done: { color: 'inactive', hex: '#9ca3af', rank: 7 },
19  stale: { color: 'subtle', hex: '#6b7280', rank: 8 },
20}
21
22/**
23 * How each status reads and looks: `label` on the pane, `tag` what the PM model reads on its board,
24 * both in the language the mod speaks now (messages.ts).
25 */
26export const STATUS = Object.fromEntries(
27  (Object.keys(LOOK) as Status[]).map(status => [
28    status,
29    {
30      ...LOOK[status],
31      get label() {
32        return t(`status.${status}.label`)
33      },
34      get tag() {
35        return t(`status.${status}.tag`)
36      },
37    },
38  ]),
39) as Record<Status, { label: string; tag: string; color: string; hex: string; rank: number }>
40
41export function isActive(status: Status): boolean {
42  return status !== 'done' && status !== 'stale'
43}
44
45/** A ledger id as people read it: T-01. */
46export function itemCode(id: number): string {
47  return `T-${String(id).padStart(2, '0')}`
48}
49
50const WAITING_MINUTES = 45
51const STALE_DAYS = 3
52/** How far the app's last activity may run ahead of a track's last write before the track has missed a turn. */
53const BEHIND_MS = 2 * 60000
54/** How long a background command counts as running without its notification: a session whose process ended never gets one. */
55const BACKGROUND_HOURS = 6
56
57/**
58 * Words a session leaves when its turn ends but work goes on: a background job, a CI run, a deploy.
59 * Each says the work is under way or what comes once it ends; a word merely named (背景偵測, the
60 * status 「等待 CI」, a command "started in the background" that a report describes) is not enough.
61 */
62const WAITING = new RegExp(
63  [
64    // 正在乾淨 worktree 裡背景執行 / 還在背景跑
65    /(正在|還在|仍在|持續)[^。\n「」]{0,20}?背景(裡|中)?(執行|跑|進行|處理|運行|監看|盯)(?!的)/,
66    // 我在背景等結果 / 已丟到背景跑 / 先放到背景執行: the speaker's own, not 「它把測試丟到背景跑」 or 丟到背景跑的指令
67    /(我|已|已經|先|會)[^。,,\n「」]{0,10}?(在|丟到|丟進|放到|放進|移到|轉到)背景(裡|中)?(執行|跑|進行|處理|等|監看|盯|運行)(?!的)/,
68    // 背景執行中 / 背景跑著
69    /背景(執行|跑|處理|運行)(中|著)/,
70    // 背景…完成後 / 背景跑完之後
71    /背景[^。\n「」]{0,20}?(完成|跑完|結束)(後|之後)/,
72    // 等 CI 通過 / 等測試跑完: not the status named in quotes or brackets (「等待 CI」, (等待 CI)) or as a noun
73    // (標成等待 CI, 維持等待 CI), not 不用等, not 等級 or 相等
74    /(?<![「『"'((\[【相平同對均高]|不用|不必|不需要|無需|別|不要)(?<!標|維持|卡在|成|為|是|算|顯示)(?<!(?:[、//→從和與及]|->)\s*(?=等待))等(?![級等於同]|待\s*CI\s*(?:[、//→,,))]|->))[^。\n「」]{0,16}?(CI|部署|測試|建置|build|跑完|完成|結果)/,
75    // 跑完會通知 / 完成後再推送
76    /(跑完|完成後)[^。\n「」]{0,8}?(會|再|就)/,
77    /自動接著/,
78    // "Waiting for CI to finish", "I'm still waiting on the checks": not a table's "Waiting on CI (等待 CI)"
79    /(^|[.!?]\s+|\n\s*)(waiting|wait) (for|on)\b|\b(I'm|I am|I'll|I will|still|now|currently|am|are|is)\s+(waiting|wait)\s+(for|on)\b/,
80    // "tests are running in the background", "still in the background": not "a command started in the background"
81    /\b(running|runs|still|now|keeps?|continues?|moved)\b[^.\n]{0,40}\bin(to)? the background|\bin the background\b[^.\n]{0,20}\b(now|still|running)\b/,
82    // "I'll tag it once the checks pass" / "once it finishes, I'll deploy"
83    /\b(I'll|I will|will)\b[^.\n]{0,60}\bonce (it|the|they)\b[^.\n]{0,30}(finish|pass|complete)|\bonce (it|the|they)\b[^.\n]{0,30}(finish|pass|complete)\w*,? I('ll| will)\b/,
84  ]
85    .map(r => r.source)
86    .join('|'),
87  'i',
88)
89
90/** Words that say CI (or the checks a release waits on) has not finished yet. */
91const CI_PENDING = new RegExp(
92  [
93    // CI 還在跑 / checks still running / CI 尚未完成: the run's own state, not "檢查兩個工具還在", a name like CI_PENDING
94    // or the next status in a list (CI 失敗/等待 CI → 背景執行 → 未完成); a re-run told of (重跑, 又跑) is not one running
95    /(CI|GitHub Actions|workflow|pipeline|checks?)[^。\n→/、]{0,16}(還在|仍在|正在|尚未|還沒|未(?!來|知)|進行中|執行中|(?<![重又])跑(?!完)|[重又]跑[中著]|\b(running|pending|in progress|queued|not (yet )?(finished|done|passed)))/,
96    /檢查[^。\n]{0,10}(還在(跑|執行|進行)|仍在(跑|執行|進行)|正在(跑|執行|進行)|尚未(跑完|完成|通過)|還沒(跑完|完成|通過|過)|進行中|執行中)/,
97    // 正在等 9f8e7d6 的 CI: room for a hash between
98    /(還在|仍在|正在)(跑|等)[^。\n]{0,16}(CI|測試|workflow|pipeline|檢查|checks?)/,
99    // 等 CI 通過: not the status named in quotes or brackets (「等待 CI」, (等待 CI)) or as a noun (不再標等待 CI, 從等待 CI 轉成,
100    // 未完成、等待 CI、CI 失敗, CI 失敗/等待 CI → 背景執行), not 不用等
101    /(?<![「『"'((\[【相平同對均高]|不用|不必|不需要|無需|別|不要)(?<!標|維持|卡在|成|為|是|算|顯示)(?<!(?:[、//→從和與及]|->)\s*(?=等待))等(?![級等於同]|待\s*CI\s*(?:[、//→,,))]|->))[^。\n]{0,16}(CI|checks?)/,
102    /(waiting|wait) (for|on) (the )?(CI|checks?|workflow|pipeline)/,
103    /(CI|checks?)[^。\n]{0,10}(通過|跑完|完成)(後|之後|再|才)/,
104    /once (the )?(CI|checks?)[^.\n]{0,20}(pass|finish|complete)/,
105  ]
106    .map(r => r.source)
107    .join('|'),
108  'gi',
109)
110
111/** Words that say CI has finished and passed. */
112const CI_PASSED = /(CI|GitHub Actions|workflow|pipeline|檢查|checks?)[^。\n]{0,12}(已|都)?(通過|跑完|全綠|綠燈|成功|passed|succeeded|green)(?!後|之後|再|才|就)/gi
113
114function lastIndex(re: RegExp, text: string): number {
115  let at = -1
116  for (const m of text.matchAll(re)) at = m.index ?? at
117
118  return at
119}
120
121/**
122 * The words as a report of the work: quoted words (a status 「等待 CI」, a step, a phrase) gone, and the
123 * clauses about a rule, an example, a condition or someone else's work blanked; separators and offsets kept.
124 */
125export function reportedText(text: string): string {
126  const parts = text.replace(QUOTED, quoted => ' '.repeat(quoted.length)).split(/([。!?!?;;,,\n]+|\.\s+)/)
127  // Blanking keeps lengths, so a sentence's offsets in `parts` are its offsets in `text`, quotes and all.
128  const lengths = parts.map(part => part.length)
129  const offsetOf = (i: number) => lengths.slice(0, i).reduce((sum, n) => sum + n, 0)
130  // Blanked to as many spaces, so an offset in it is one in `text`.
131  const blank = (i: number) => {
132    parts[i] = ' '.repeat(parts[i]!.length)
133  }
134  // Another session spoken of as 它 or by its link, or a rule laid out (流程是, 規範是): the sentence's next clauses
135  // without an I of their own go on with it (接下來它會推送,CI 通過後部署; 規範是先打標籤,通過後才推標籤).
136  let isCarried = false
137  for (let i = 0; i < parts.length; i += 2) {
138    const part = parts[i]!
139    if (i >= 2 && !/^[,,]+$/.test(parts[i - 1]!)) isCarried = false
140    if (THEIRS.test(part) || RULE_LEAD.test(part)) isCarried = true
141    if (ABOUT_A_RULE.test(part) || (isCarried && !FIRST_PERSON.test(part))) blank(i)
142    else if (DISCLAIMER.test(part)) {
143      blank(i)
144      // 那些未提交的修改,我都沒有動: what is disclaimed often stands just before, in the same sentence,
145      // with no I of its own (我還沒 push,別人的修改我沒碰 keeps its first half).
146      if (i >= 2 && /^[,,]+$/.test(parts[i - 1]!) && !FIRST_PERSON.test(parts[i - 2]!)) blank(i - 2)
147    }
148  }
149  // A sentence that tells how something is judged (判成, 誤判, 列為, flags as) describes the judging: all of it,
150  // before and after that clause, but a clause with an I of its own (看板會判成待發布,因為我還沒 push).
151  for (let start = 0; start < parts.length; ) {
152    let end = start
153    while (end + 2 < parts.length && /^[,,]+$/.test(parts[end + 1]!)) end += 2
154    if (isJudging(text.slice(offsetOf(start), offsetOf(end) + parts[end]!.length))) {
155      for (let i = start; i <= end; i += 2) if (!FIRST_PERSON.test(parts[i]!)) blank(i)
156    }
157    start = end + 2
158  }
159
160  return parts.join('')
161}
162
163/** The latest turn says CI has not finished (and says nothing later about it passing). */
164export function isCiPending(text: string): boolean {
165  const plain = reportedText(text)
166  const pending = lastIndex(CI_PENDING, plain)
167
168  return pending >= 0 && pending > lastIndex(CI_PASSED, plain)
169}
170
171/** What the session said in its latest turn: its recap and final answer, the later one last. */
172export function latestWords(track: Track): string {
173  const words = [track.recap ?? '', track.lastAnswer]
174  // A recap written after the last turn ended belongs to the turn still going: it is the newer word.
175  if ((track.recapAt ?? 0) > track.turnAt) words.reverse()
176
177  return words.filter(Boolean).join('\n')
178}
179
180/** The words statusOf reads for CI: the track's latest turn, else the transcript's last word. */
181export function ciWords(track: Track | undefined, word: LastWord | undefined): string {
182  return track ? latestWords(track) : (word?.said ?? '')
183}
184
185/**
186 * The runs and commits a session's words name, the likeliest first: nearest to where the report itself
187 * talks of CI (not in a quote or a rule), then the later one. A commit is seven to forty hex digits
188 * with a digit and a letter (3fa1c2e, not 1204 or "deadbeef") that is not part of an id (local_…-9e0f1a2b3c4d);
189 * the caller tries them in turn against the session's repository.
190 */
191export function ciTargets(text: string): { runs: string[]; shas: string[] } {
192  const plain = reportedText(text)
193  const ci = [...plain.matchAll(/CI|GitHub Actions|workflow|pipeline|checks?|檢查/gi)].map(m => m.index ?? 0)
194  const rank = (found: { word: string; at: number }[]) =>
195    found
196      .map(f => ({ ...f, distance: ci.length ? Math.min(...ci.map(c => Math.abs(c - f.at))) : 0 }))
197      .sort((a, b) => a.distance - b.distance || b.at - a.at)
198      .map(f => f.word)
199      .filter((w, i, all) => all.indexOf(w) === i)
200  const runs = [...text.matchAll(/(?:actions\/runs\/|\brun\s*(?:id\s*)?[#::]?\s*)(\d{8,12})\b/gi)].map(m => ({ word: m[1]!, at: (m.index ?? 0) + m[0].length - m[1]!.length }))
201  const shas = [...text.matchAll(/(?<![0-9a-z_-])[0-9a-f]{7,40}(?![0-9a-z_-])/gi)]
202    .filter(m => /\d/.test(m[0]) && /[a-f]/i.test(m[0]))
203    .map(m => ({ word: m[0].toLowerCase(), at: m.index ?? 0 }))
204
205  return { runs: rank(runs), shas: rank(shas) }
206}
207
208/** The likeliest CI the words name: a run id, else a commit. */
209export function ciTarget(text: string): { run: string } | { sha: string } | null {
210  const { runs, shas } = ciTargets(text)
211  if (runs[0]) return { run: runs[0] }
212
213  return shas[0] ? { sha: shas[0] } : null
214}
215
216/** What GitHub's runs for a commit (or one run) come to: any still going is pending, any failed is failure. */
217export type CiRun = { status?: string; conclusion?: string | null; workflowName?: string }
218
219/**
220 * What GitHub's runs for a commit (or one run) come to: any still going is pending, any failed is failure.
221 *
222 * A cancelled run is not a failure. One whose workflow has another run on the same commit (re-run, or run
223 * again after it was called off) is left out; a workflow whose runs were all cancelled never finished, so
224 * the commit has not passed either: unknown, and a newer commit or the words decide.
225 */
226export function ciVerdict(runs: CiRun[]): CiCheck['state'] {
227  const isCancelled = (r: CiRun) => r.status === 'completed' && r.conclusion === 'cancelled'
228  const workflow = (r: CiRun) => r.workflowName ?? ''
229  const counted = runs.filter(r => !isCancelled(r))
230  const unfinished = runs.some(r => isCancelled(r) && !counted.some(c => workflow(c) === workflow(r)))
231  if (counted.some(r => r.status !== 'completed')) return 'pending'
232  if (counted.some(r => !['success', 'skipped', 'neutral'].includes(String(r.conclusion)))) return 'failure'
233
234  return counted.length === 0 || unfinished ? 'unknown' : 'success'
235}
236
237/**
238 * Of two answers for the same work, the one that says more for it: a commit is as good as anything that
239 * holds it, so a newer build that passed clears an older one that failed or was called off, and one still
240 * running keeps it pending. success, then pending, then failure, then unknown.
241 */
242export function betterCi(a: CiCheck['state'], b: CiCheck['state']): CiCheck['state'] {
243  const order: CiCheck['state'][] = ['success', 'pending', 'failure', 'unknown']
244
245  return order.indexOf(a) <= order.indexOf(b) ? a : b
246}
247
248/**
249 * Whether a track has missed the session's latest turns: the app saw activity well after the
250 * track's last write while the session sits idle (a module that lost its track for a while).
251 */
252export function isBehind(one: Session, track: Track): boolean {
253  return !one.isRunning && (Date.parse(one.lastActivityAt) || 0) - Math.max(track.turnAt, track.planAt) > BEHIND_MS
254}
255
256/**
257 * The tracks as they stand against the transcripts: a track behind its session takes the
258 * transcript's last word for its words (its recap and question were an older turn's) and keeps its task list.
259 */
260export function liveTracks(list: Session[], tracks: Record<string, Track>, words: Record<string, LastWord>): Record<string, Track> {
261  const live: Record<string, Track> = { ...tracks }
262  for (const one of list) {
263    const track = tracks[one.id]
264    const word = words[one.id]
265    if (!track || !isBehind(one, track) || !word || word.error || !word.said || word.activity !== one.lastActivityAt) continue
266    // Its background list missed the notifications those turns got too.
267    live[one.id] = { ...track, recap: null, recapAt: undefined, ask: word.isAsking ? word.said : null, lastAnswer: word.said, background: [] }
268  }
269
270  return live
271}
272
273/** Words a recap uses for work left other than delivering it: not done, not verified, not tested (delivery: undeliveredOf). */
274const CAVEAT = /(還沒|尚未|沒有|未)(完成|驗證|確認|測試|處理|做)|not (yet )?(done|verified|tested)|still (needs|to do)|TODO/i
275
276/** A double negative that says nothing is left: 沒有任何未提交的修改, 沒有未推送的 commit. */
277const NOTHING_LEFT = /(沒有|没有|並無|并无|無|不再有)(任何|其他|別的|剩下|剩餘|留下)?[^。,,;;!!\n]{0,4}?(還沒|尚未|未)/g
278
279export type Project = { key: string; name: string; path: string; sessions: Session[] }
280
281/** The project a session works on: a worktree counts as its repository. */
282export function projectPath(cwd: string): string {
283  const cut = cwd.search(/[\\/]\.(claude[\\/]worktrees|worktrees)[\\/]/i)
284
285  return (cut >= 0 ? cwd.slice(0, cut) : cwd).replace(/[\\/]+$/, '')
286}
287
288export function isScratch(cwd: string): boolean {
289  return /scratch-workspaces/i.test(cwd)
290}
291
292export function projectName(cwd: string): string {
293  if (isScratch(cwd)) return t('project.scratch')
294
295  return projectPath(cwd).split(/[\\/]/).filter(Boolean).at(-1) ?? t('project.unsorted')
296}
297
298export function projectKey(cwd: string): string {
299  return isScratch(cwd) ? 'scratch' : projectPath(cwd).replace(/\//g, '\\').toLowerCase()
300}
301
302/** A short stable file name for a project key. */
303export function keyFile(key: string): string {
304  let h = 5381
305  for (let i = 0; i < key.length; i++) h = ((h << 5) + h + key.charCodeAt(i)) >>> 0
306  const name = key.split(/[\\/]/).filter(Boolean).at(-1) ?? 'project'
307
308  return `${name.replace(/[^\p{L}\p{N}_-]+/gu, '_').slice(0, 40)}-${h.toString(16)}`
309}
310
311/** Projects, most recently active first; within one, the latest session first. */
312export function groupProjects(list: Session[]): Project[] {
313  const byKey = new Map<string, Project>()
314  for (const one of list) {
315    const key = projectKey(one.cwd)
316    let project = byKey.get(key)
317    if (!project) {
318      project = { key, name: projectName(one.cwd), path: projectPath(one.cwd), sessions: [] }
319      byKey.set(key, project)
320    }
321    project.sessions.push(one)
322  }
323  const at = (s: Session) => Date.parse(s.lastActivityAt) || 0
324  for (const project of byKey.values()) project.sessions.sort((a, b) => at(b) - at(a))
325
326  return [...byKey.values()].sort((a, b) => at(b.sessions[0]!) - at(a.sessions[0]!))
327}
328
329/**
330 * Whether a track holds anything the session reported. A track a session writes when it
331 * (re)loads, before its next turn ends, holds nothing: its transcript's last word says more.
332 */
333export function hasReport(track: Track | null | undefined): track is Track {
334  return !!track && (track.plan.length > 0 || !!track.lastAnswer || !!track.recap || !!track.ask)
335}
336
337/** The background tasks a notification in `text` reports as over: completed, failed, killed or stopped. */
338export function finishedTasks(text: string): string[] {
339  const ids: string[] = []
340  for (const m of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
341    const body = m[1] ?? ''
342    const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(body)?.[1]
343    const status = /<status>\s*([^<\s]+)\s*<\/status>/.exec(body)?.[1] ?? ''
344    if (id && !/^(running|pending|started|in_progress)$/i.test(status)) ids.push(id)
345  }
346
347  return ids
348}
349
350/** The background commands a session started and has not heard back from (within BACKGROUND_HOURS of their start). */
351export function backgroundOf(track: Track | undefined, now: number): BackgroundTask[] {
352  return (track?.background ?? []).filter(t => now - t.at < BACKGROUND_HOURS * 3600000)
353}
354
355export function openSteps(track: Track | undefined): Step[] {
356  return track ? track.plan.filter(s => s.status !== 'completed') : []
357}
358
359// ── Work not delivered yet: not committed, not pushed, not released, waiting on a go-ahead ──
360
361/** What delivering takes, as the words name it. */
362export type DeliveryKind = 'commit' | 'push' | 'release'
363
364const VERB = String.raw`commit(?:ted)?|push(?:ed)?|deploy(?:ed)?|release[sd]?|merge[sd]?|ship(?:ped)?|tagged|提交|推送|推上|發布|發佈|部署|上線|合併|交付|升版|打標籤|打\s?tag`
365/** A delivery verb, and those joined to it: 沒有 commit 或 push, not committed or pushed. */
366const VERBS = String.raw`(?:${VERB})(?![^\s,。,;;]{0,2}(?:設定|腳本|流程|文件|檔案|權限|環境|相關|說明|紀錄|動詞))(?:\s*(?:或|和|、|及|跟|與|並|再|也沒有?|/|/|,|and|or|nor)\s*(?:${VERB}))*`
367/** 尚未 commit, 還沒上線, 仍未 commit 或 push, 也沒有 push: the negation a few characters before the verb (a verb after 的 is a noun: 還沒公開的 commit). */
368const NOT_DONE = new RegExp(String.raw`(尚未|還沒有?|仍未|仍然沒有|並未|并未|沒有?|未)[^。,,;;::\n]{0,4}?(?<!的\s*)(${VERBS})|\b(not(?: yet)?|haven't(?: yet)?|hasn't(?: yet)?|have not(?: yet)?|has not(?: yet)?|didn't|did not)\s+(?:been\s+)?(${VERBS})`, 'gi')
369/**
370 * A negation that says the work is still to come (還沒, 尚未, 未, not yet, not pushed, haven't pushed): it counts. A
371 * bare one (沒有 push, 並未部署, didn't deploy) only says it was not done this time, as often because it was not
372 * needed (only tests changed, the project's rules ask no tag) as because it is left: it counts only in a sentence
373 * that waits on something. What is really left of commit and push git shows (PushCheck); of a release, the
374 * session's declaration (declaredOf).
375 */
376const YET = /還|尚|仍|^未$|yet|^not$|haven't|hasn't|have not|has not/i
377/**
378 * Not needed: 不需要 push, 無需發布, 不用升版號, no need to push, nothing to commit. The kinds it names are settled,
379 * like done ones (版號, 標籤 and tag are a release's).
380 */
381const NO_NEED = new RegExp(
382  String.raw`(?:不需要|不需|不用|無需|無須|毋需|毋須|不必|沒必要)\s*(?:再|做|進行)?\s*((?:${VERBS})|版號|升版|打標籤|標籤|tag)|\b(?:no need to|nothing to|don't need to|do not need to|doesn't need to|does not need to)\s+(?:be\s+)?((?:${VERBS}))`,
383  'gi',
384)
385/**
386 * The work still sits here: 只在本機, 還在本機, 留在本機, 放在本機, only locally. Said of it, 沒有 push and 沒有 commit
387 * say it is left, as a wait does.
388 */
389const STILL_LOCAL = /(?:還|仍|只|都只|僅)(?:存|留|放)?在[^,。,;;\n]{0,10}?(?:本機|本地)|(?:留|放)在本機|\bonly (?:on this machine|locally)\b|\bstill local\b|\blocal only\b/i
390/** The work waits on something before it is done: 等 PM 確認, 確認後再推, pending, until. A bare negation then counts. */
391const WAITING_TONE = /等(?!於)|待(?!發布|處理事項)|之後(?:再|才)|稍後|晚點|once\b|\buntil\b|\bpending\b|\bwaiting\b|\bafter (?:the )?(?:PM|review|approval|go-ahead)/i
392/**
393 * Its own delivery still to come, said as what happens next with no negation: 之後再 push, 接下來部署到正式站,
394 * 明天發布 2.4.2, PM 確認後我再推, 稍後 commit, I'll push after review, Next: deploy to production. A time or a step
395 * ahead, then (a few characters on) the verb; 推 alone is a push here (再推, 才推). Not a verb used as a noun:
396 * 多了兩個 commit, 合併後的提交, 之後部署會改成, commit 再 push 就會更新.
397 */
398const FUTURE = new RegExp(
399  String.raw`(?:之後|稍後|待會兒?|晚點|接下來|下一步|再來|明天|今晚|下午|下週|隨後|然後|預計|打算|準備|將|(?:確認|通過|審核|審查|放行|review|合併|完成|跑完|結束)後)[^。,,;;::\n]{0,6}?(?<![個筆次條份的]\s*)((?:${VERBS})|推(?![薦測論動進出廣]))(?!\s*(?:[就便]?會|時|的))|\b(?:I'll|I will|I'm going to|I am going to|we'll|we will|will|going to|next[:,]|then|later|tomorrow)\s+(?:then\s+)?((?:${VERBS}))`,
400  'gi',
401)
402/** The time or step a future match opens with, to read what follows it. */
403const FUTURE_MARKER = /^(?:之後|稍後|待會兒?|晚點|接下來|下一步|再來|明天|今晚|下午|下週|隨後|然後|預計|打算|準備|將|(?:確認|通過|審核|審查|放行|review|合併|完成|跑完|結束)後)/i
404/**
405 * What follows the marker when the order is told after the fact: 才 with no one doing it next (CI success 之後才推標籤,
406 * a step in the order things went). Not 才能, not 才由我推: those are still to come.
407 */
408const AFTER_THE_FACT = /^\s*才(?!能)(?![^。,,;;\n]*?我)/
409/**
410 * A sentence still looking ahead, anywhere in it: then 之後才推 is the plan (我還在等 2.4.0 部署完成,之後才推 2.4.1;
411 * 我會先申請放行,放行後才 push), not the order things went.
412 */
413const STILL_AHEAD = /會|要|等|再|將|打算|準備|預計|還在|\byet\b/i
414/**
415 * A delivery that waits on something before it: 2.4.2 部署前還要等 PM, 推送前要等 CI. The kind is still to come.
416 * Not a fact of what came before (部署前有備份, 推之前檢查通過), nor a step to take first (推送前要先 rebase): an
417 * instruction, often for whoever does it next.
418 */
419const BEFORE_IT = new RegExp(String.raw`((?:${VERB})|推(?![薦測論動進出廣]))\s*(?:之)?前[^。,,;;\n]{0,6}?(?:還要等|要等|得等|需等|需要等|等待|待)`, 'gi')
420
421/**
422 * After a future verb, what makes it a consequence, a noun or a fact: (more verbs, then) 就會, 便會, 會, 時, 的;
423 * a commit hash (之後另外提交 7fcc930: a commit to come has none yet); a 就/便…會 soon after (之後推送、SSH 就不會再詢問).
424 */
425const NOUN_AFTER = new RegExp(
426  String.raw`^\s*(?:(?:或|和|、|及|跟|與|並|再|and|or)\s*(?:${VERB})\s*)*(?:[就便]?會|時|的|` + '`?' + String.raw`[0-9a-f]{7,40}\b)|^[^。,,;;\n]{0,8}?[就便]不?會`,
427  'i',
428)
429/**
430 * A future that will not come (不會再部署, won't deploy), a past told in order (之後它完成部署, 之後補了一個提交),
431 * a guess (今晚可能還會推送, might deploy), or a noun that follows the marker (接下來的推版).
432 */
433const NOT_FUTURE = /不|沒|別|無需|毋需|了|已|完成|過|可能|也許|或許|大概|^的|won't|will not|never|no longer|\bmaybe\b|\bprobably\b|\bmight\b/i
434/**
435 * Words that leave the next step to the person: 你可以之後再 push, 下一步可以部署, 建議, 要不要, you can, you could.
436 * An offer is theirs to take (a question already shows the session as waiting on them), not this session's to-do.
437 */
438const OFFER = /你可以|您可以|你也可以|您也可以|你要|可以|建議|要不要|是否要|\byou (?:can|could|may|might)\b|\bwould you\b|\bfeel free\b|\boptional(?:ly)?\b|\bif you (?:want|like)\b/i
439
440/**
441 * The person's own plan, not this session's: 你原本打算上線後自己改, you'll deploy it. Not 幫你, 給你 or 通知你,
442 * where the session still does the work.
443 */
444const YOURS = /(?<![幫給替向跟和對讓與知訴])(?:你|您)(?!的)|\byou(?:'ll| will| plan| were going| are going)\b/i
445
446/**
447 * Between a negation and its verb, how the work was done rather than that it was not: 沒有另開 commit, 沒有另外 commit,
448 * 沒有新增 commit, 沒有拆成兩個 commit, 沒有用 force push, 沒有先手動 merge, 沒有直接 push 到 main.
449 */
450const MANNER =
451  /另(?:開|外|行|起)?|新增|再開|多開|額外|單獨|拆成|拆開|分成|分開|先|手動|自動|直接|透過|經由|用|force|強制|separate|another|extra|additional|split|manually|directly|first/i
452/**
453 * A commit made, named by its hash: commit 是 3fa1c2e, 新的 commit 為 3fa1c2e, amend 成 3fa1c2e, 併進 3fa1c2e,
454 * committed (as) 3fa1c2e, amended into 3fa1c2e.
455 */
456const COMMITTED =
457  /\bcommit\s*(?:是|為|:|:)\s*`?[0-9a-f]{7,40}\b|\bamend(?:ed)?\s*(?:成|為|到|into|to|as)?\s*`?[0-9a-f]{7,40}\b|(?:併進|併入|合進|合入)\s*`?[0-9a-f]{7,40}\b|\bcommitted\s+(?:it\s+)?(?:as\s+|to\s+|in\s+)?`?[0-9a-f]{7,40}\b/gi
458
459/** 已推上 main, 已部署, 部署完成, 上線了, pushed to main: the same kinds done, which a later word may say. */
460const DONE = new RegExp(String.raw`(?:已經?|都已|也已|皆已|完成(?!後))[^。,,;;::\n]{0,4}?(${VERBS})|(${VERB})(?:完成|好了|成功|了)|\b(?:I|we|it|was|were|is|has been|have been)\s+(committed|pushed|deployed|released|merged|shipped)\b`, 'gi')
461/**
462 * Delivery waits on someone's word: 等 PM 放行, 等 PM 通知 2.4.1 上線後才發布, 等確認才 push, waiting for
463 * your go-ahead. Not 等 PM 下一輪確認 (a check to come, with nothing to deliver after it).
464 */
465const AWAITING_GO = new RegExp(
466  String.raw`等[^。,,;;\n]{0,10}?(?:放行|批准|核准|同意)|等[^。,,;;\n]{0,12}?(?:通知|確認)[^。;;\n]{0,16}?(?:後|再|才)[^。;;\n]{0,16}?(?:${VERB})|\bwaiting (?:for|on) (?:the PM's |the PM |your |a |an |the )?(?:go-ahead|approval|sign-off|OK)\b`,
467  'gi',
468)
469/**
470 * A clause about a rule, a status or an example, not about the work: 看板會把它判成待發布, 這類字眼, 就不能顯示已結束;
471 * a condition (回報說還沒交付時, 如果還沒部署), words reported (摘要提到尚未部署), or someone else's work
472 * (其他 Session 還沒提交的修改都原樣留著: a folder shared with other sessions).
473 */
474const ABOUT_A_RULE =
475  /另一個 ?Session|另個 ?Session|另外一個 ?Session|(?:另一個|另個|另外一個|另一|其他|別的|別個)\s*(?:Session|session|工作階段|人|同事|版本|分支|專案|工作|任務|項目)|(?:由|交給|交由)\s*[^\s,,。]{1,12}?\s*(?:負責|管|處理|接手|主導)|子 ?Session|子工作階段|的\s*(?:[A-Za-z0-9_.-]+\s*)?(?:Session|工作階段)|它(?:們)?(?=會|將|就|再|要|先|已|還|也|正|在|自己|負責)|\(#local_|其他人|別人|他人|其餘|another session|other sessions?|someone else|someone's|判|列為|列進|標成|標為|視為|字眼|說法|正則|regex|正例|反例|這類|例如|像是|顯示|不能是|不算|才算|當成|當作|被當|(?<![小同即當準及按暫])時\s*$|(?<![小同即當準及按暫])時(?:就|會|才|便|也|一樣|照樣|仍|都)|的時候|的話|如果|假如|只要|一旦|回報(?:裡|中|上)?[^,,。]{0,4}?(?:說|寫|宣告|聲明|提到)|(?:流程|步驟|順序|做法|規則|規範|規定|慣例)(?:是|為|:|:)|\bthe (?:process|flow|order|rule) is\b|摘要[^,,。]{0,4}?(?:說|寫|宣告|聲明|提到)|提到|e\.g\.|for example|such as|\bif\b|\bwhen\b/i
476/**
477 * A clause that disowns what it speaks of: 我都沒有動, 沒碰, 不是我的, 不屬於這次, not mine, I didn't touch.
478 * Its words, and the subject-less clause it disowns just before it, say nothing about this session's work.
479 */
480const DISCLAIMER =
481  /(?<![還尚仍])(?:都|也|並|完全)?沒(?:有)?(?:動|碰|改動)(?![手工])(?:過|到)?|沒碰|不是我的|不是這次的|不屬於(?:這次|本次|我)|與我無關|not mine|isn't mine|aren't mine|weren't mine|\bI (?:didn't|did not|haven't|have not) touch|left (?:them|it|those|that) (?:alone|untouched)|untouched/i
482/** Another session spoken of as 它 (it) or by its link, whose words may run on into the next clauses. */
483const THEIRS = /它(?:們)?(?=會|將|就|再|要|先|已|還|也|正|在|自己|負責)|\(#local_/
484/** A rule or a process laid out, whose words may run on into the next clauses: 流程是, 規範是, the process is. */
485const RULE_LEAD = /(?:流程|步驟|順序|做法|規則|規範|規定|慣例)(?:是|為|:|:)|\bthe (?:process|flow|order|rule) is\b/i
486/** A clause with its own I: what it says is this session's own. */
487const FIRST_PERSON = /我|自己|這次|本次|\bI\b|\bwe\b|\bmy\b|\bour\b/i
488/** The board, or what judges for it. */
489const BOARD = /看板|\bboard\b|偵測器?|\bdetector\b/i
490/** Judging, but not a misjudgment fixed (修了一個誤判, 誤判已修好: the work done, not the board's judging). */
491const JUDGE =
492  /判成|判定|判為|(?<!修[好掉正了復]?\s*(?:了|完)?\s*(?:一個|這個|那個|兩個|幾個)?\s*小?)(?:誤判|漏判|誤擋|漏擋)(?!\s*(?:已經?|都)?(?:修好|修正|修掉|解決))|列為|列進|標成|標為|顯示成|顯示為|\bflag(?:s|ged)? (?:it |them |this |that )?as\b|\bmark(?:s|ed)? (?:it |them |this |that )?as\b|\breads? (?:it |them |this |that )?as\b|\bmisread/i
493/** A judging verb with a status after it: 列為待發布, 判成「已結束」, 標成需追蹤. */
494const JUDGED_AS = /(?:判成|判為|列為|列進|標成|標為|顯示成|顯示為)\s*[「『"]?\s*(?:待發布|需追蹤|已結束|等待 ?CI|背景執行|待回覆|未完成|CI ?失敗|長期閒置|中斷)/
495
496/**
497 * A sentence that describes a judgment (the board's, a rule's) rather than this session's work: the board and a
498 * judging word in it, or a judging verb with a status after it. Not 看板 or a status name alone (a product may
499 * have a dashboard; 「2.4.3 待發布,還沒 push」 is a session's own state), not 列為 alone (列為待確認事項).
500 */
501function isJudging(sentence: string): boolean {
502  return (BOARD.test(sentence) && JUDGE.test(sentence)) || JUDGED_AS.test(sentence)
503}
504/** What a possessive (X 的未提交修改) may name and still be this session's own: 我, this turn, now, here. */
505const OWN = /我|自己|本次|這次|這個 ?Session|這邊|目前|現在|本機|本地|手上|剩下|剩餘|\bmy\b|\bour\b|this session/i
506
507/**
508 * The commit the words name as theirs: commit 是 3fa1c2e, 本機 commit **3fa1c2e**, 提交為 3fa1c2e, 本機提交(3fa1c2e),
509 * amend 成 3fa1c2e, 併進 3fa1c2e, committed as 3fa1c2e. Not a hash named otherwise (以 3fa1c2e 為基準, 取代原本的 9b8c7d6).
510 */
511const OWN_COMMIT =
512  /(?:\bcommit(?:ted)?|提交)\s*(?:是|為|成|:|:|hash|as|to|in)?\s*[((]?\s*[`*]*([0-9a-f]{7,40})\b|\bamend(?:ed)?\s*(?:成|為|到|into|to|as)?\s*[`*]*([0-9a-f]{7,40})\b|(?:併進|併入|合進|合入)\s*[`*]*([0-9a-f]{7,40})\b/i
513
514export function ownCommitOf(text: string): string | null {
515  const m = OWN_COMMIT.exec(text)
516
517  return (m?.[1] ?? m?.[2] ?? m?.[3])?.toLowerCase() ?? null
518}
519
520/** The commit the words name: the hash after "commit", else the first one that is not part of an id (local_…-9e0f1a2b3c4d). */
521function commitOf(text: string): string | null {
522  const named = ownCommitOf(text)
523  if (named) return named
524  const any = [...text.matchAll(/(?<![0-9a-z_-])[0-9a-f]{7,40}(?![0-9a-z_-])/gi)].map(m => m[0]).find(w => /\d/.test(w) && /[a-f]/i.test(w))
525
526  return any?.toLowerCase() ?? null
527}
528/** Quoted words name something (a status, a step, a phrase); backticks hold code and hashes. */
529const QUOTED = /「[^」]*」|『[^』]*』|“[^”]*”|"[^"\n]*"|`[^`\n]*`/g
530
531function deliveryKind(verb: string): DeliveryKind {
532  if (/^(commit|committed|提交)$/i.test(verb)) return 'commit'
533  if (/^(push|pushed|推送|推上)$/i.test(verb)) return 'push'
534
535  return 'release'
536}
537
538function verbsIn(segment: string): DeliveryKind[] {
539  return [...segment.matchAll(new RegExp(VERB, 'gi'))].map(m => deliveryKind(m[0]))
540}
541
542/**
543 * What the words say is not delivered yet, read sentence by sentence: what a kind was last said to be
544 * (not done, or done later on) decides it. A sentence about a rule or an example, quoted words and a
545 * double negative (沒有任何未提交的修改) say nothing about the work.
546 */
547export function undeliveredOf(text: string): { kinds: DeliveryKind[]; sha: string | null } | null {
548  const last = new Map<DeliveryKind, boolean>()
549  // Rules, examples, quotes, someone else's work and what a clause disowns are gone (reportedText) before
550  // anything is read: of what is left, a kind's last word stands.
551  const plain = reportedText(text.replace(NOTHING_LEFT, ''))
552  // Sentences with what ends them: a question mark makes the sentence a question to the person.
553  const pieces = plain.split(/([。!?!?;;\n]+|\.\s+)/)
554  for (let p = 0; p < pieces.length; p += 2) {
555    const whole = pieces[p]!
556    const isQuestion = /[??]/.test(pieces[p + 1] ?? '')
557    // A wait anywhere in the sentence (沒有 push,等 PM 確認), or work still sitting here (只在本機,沒有 push), keeps
558    // its bare negations: the work is still to come.
559    const isWaiting = WAITING_TONE.test(whole) || STILL_LOCAL.test(whole)
560    for (const sentence of whole.split(/[,,]+/)) {
561      if (!sentence.trim()) continue
562      const said: { at: number; kind: DeliveryKind; isLeft: boolean }[] = []
563      for (const m of sentence.matchAll(NOT_DONE)) {
564        // 版本 2.4.2 的未提交修改: undone, but someone else's, unless the owner named is this session.
565        const before = sentence.slice(0, m.index ?? 0)
566        const owner = /([^\s,,。]+(?:\s+[^\s,,。]+)?)\s*的\s*$/.exec(before)?.[1]
567        if (owner && !OWN.test(owner)) continue
568        // 沒有另開 commit, 沒有新增 commit, 沒有用 force push: how it was done, not that it was not.
569        const negation = m[1] ?? m[3] ?? ''
570        const verbs = m[2] ?? m[4] ?? ''
571        if (MANNER.test(m[0].slice(negation.length, m[0].length - verbs.length))) continue
572        // 沒有打標籤也沒有部署: not done this time, not said to be left (YET). Unless the sentence waits on something.
573        const isYet = YET.test(negation) || /^\s*yet\b/i.test(sentence.slice((m.index ?? 0) + m[0].length))
574        if (!isYet && !isWaiting) continue
575        for (const kind of verbsIn(m[2] ?? m[4] ?? '')) said.push({ at: m.index ?? 0, kind, isLeft: true })
576      }
577      for (const m of sentence.matchAll(DONE)) for (const kind of verbsIn(m[1] ?? m[2] ?? m[3] ?? '')) said.push({ at: m.index ?? 0, kind, isLeft: false })
578      for (const m of sentence.matchAll(COMMITTED)) said.push({ at: m.index ?? 0, kind: 'commit', isLeft: false })
579      for (const m of sentence.matchAll(BEFORE_IT)) {
580        const named = m[1] ?? ''
581        for (const kind of /^推$/.test(named) ? (['push'] as const) : verbsIn(named)) said.push({ at: m.index ?? 0, kind, isLeft: true })
582      }
583      for (const m of sentence.matchAll(NO_NEED)) {
584        const named = m[1] ?? m[2] ?? ''
585        const kinds = /版號|升版|標籤|tag/i.test(named) ? (['release'] as const) : verbsIn(named)
586        for (const kind of kinds) said.push({ at: m.index ?? 0, kind, isLeft: false })
587      }
588      for (const m of sentence.matchAll(AWAITING_GO)) said.push({ at: m.index ?? 0, kind: 'release', isLeft: true })
589      // Its own delivery said as what comes next: not an offer to the person, not a question to them, not a "won't".
590      if (!OFFER.test(sentence) && !YOURS.test(sentence) && !isQuestion) {
591        for (const m of sentence.matchAll(FUTURE)) {
592          // The marker's own words (通過後) aside: a negation or a past in what follows it.
593          if (AFTER_THE_FACT.test(m[0].replace(FUTURE_MARKER, '')) && !STILL_AHEAD.test(whole)) continue
594          if (NOT_FUTURE.test(m[0].replace(/^(?:之後|稍後|待會兒?|晚點|接下來|下一步|再來|明天|今晚|下午|下週|隨後|然後|預計|打算|準備|將|(?:確認|通過|審核|審查|放行|review|合併|完成|跑完|結束)後)/i, ''))) continue
595          // A verb that goes on into a consequence or a noun (commit 再 push 就會更新, 部署時, 部署的): not a step to take.
596          if (NOUN_AFTER.test(sentence.slice((m.index ?? 0) + m[0].length))) continue
597          const named = m[1] ?? m[2] ?? ''
598          const kinds = /^推$/.test(named) ? (['push'] as const) : verbsIn(named)
599          for (const kind of kinds) said.push({ at: m.index ?? 0, kind, isLeft: true })
600        }
601      }
602      for (const one of said.sort((a, b) => a.at - b.at)) last.set(one.kind, one.isLeft)
603    }
604  }
605  const kinds = (['commit', 'push', 'release'] as const).filter(kind => last.get(kind))
606
607  return kinds.length ? { kinds, sha: commitOf(text) } : null
608}
609
610/**
611 * The delivery a session declares, one line in the form the mod asks of the sessions of a project with a PM
612 * (role.delivery): 交付:不需要部署, 交付:已部署 2.4.1, 交付:待部署(等 PM 放行); Delivery: not needed, deployed,
613 * pending (waiting on …). `pending` when the line says anything is still to deploy, else `none` or `done` by how it
614 * starts; null with no such line, or one that says none of these. The last line stands.
615 */
616const DECLARED = /^[\s>*_•-]*(?:\*\*)?(?:交付|Delivery)(?:\*\*)?\s*[::]\s*(?:\*\*)?(.+)$/gim
617
618export function declaredOf(text: string): 'none' | 'done' | 'pending' | null {
619  let value: string | null = null
620  for (const m of text.matchAll(DECLARED)) value = m[1]!
621  if (value === null) return null
622  if (/待(?:部署|發布|發佈|上線)|還要(?:部署|發布|上線)|尚未(?:部署|發布|上線)|^\s*(?:待|等)|\bpending\b|\bwaiting\b|\bnot yet\b/i.test(value)) return 'pending'
623  if (/^\s*(?:不需要?|不必|不用|無需|無須|毋需|免)|^\s*(?:none|not needed|no (?:deploy|release)|n\/a)\b/i.test(value)) return 'none'
624  if (/^\s*(?:已|完成)|^\s*(?:done|deployed|released|shipped|live)\b/i.test(value)) return 'done'
625
626  return null
627}
628
629/**
630 * What a session has left to deliver, facts first. Commit and push: git's answer for these words (PushCheck), when
631 * there is one: a clean folder has nothing left to commit; the commit the words name, or a clean folder's HEAD, on a
632 * remote branch has nothing left to push; the words' own commit (ownCommitOf) on none has its push left. A release (tag, deploy): the session's declaration, when it made one.
633 * Otherwise the words (undeliveredOf), which count only work said to be left.
634 */
635export function leftOf(text: string, push: PushCheck | undefined): DeliveryKind[] {
636  const kinds = new Set(undeliveredOf(text)?.kinds ?? [])
637  const declared = declaredOf(text)
638  if (declared === 'pending') kinds.add('release')
639  else if (declared) kinds.delete('release')
640  if (push?.words === text) {
641    // The words' own commit, in this repository and on no remote branch: not pushed, whatever the words say.
642    if (push.isOwn && !push.isPushed) kinds.add('push')
643    if (push.isPushed) kinds.delete('push')
644    if (push.isClean) kinds.delete('commit')
645  }
646
647  return (['commit', 'push', 'release'] as const).filter(kind => kinds.has(kind))
648}
649
650function isUndelivered(text: string, push: PushCheck | undefined): boolean {
651  return leftOf(text, push).length > 0
652}
653
654/** Runs git with these arguments in the session's folder: its standard output, or null when it failed. */
655export type Git = (args: string[]) => Promise<string | null>
656
657/** More files than this, and a commit's content is not compared (an argument list has its limits): it reads as not pushed. */
658const CONTENT_FILES = 200
659/** How many of a remote branch's commits that touched those files are compared, newest first. */
660const CONTENT_COMMITS = 20
661/** How many local branches holding a commit, and of its descendants on each, are tried, newest first. */
662const DESCENDANT_TIPS = 3
663const DESCENDANTS = 5
664
665/** Each file's blob in `commit`, from `git ls-tree -r -z`; a file absent there is absent here. */
666async function blobsOf(git: Git, commit: string, files: string[]): Promise<Map<string, string> | null> {
667  const out = await git(['ls-tree', '-r', '-z', commit, '--', ...files])
668  if (out === null) return null
669  const blobs = new Map<string, string>()
670  for (const entry of out.split('\0').filter(Boolean)) {
671    const tab = entry.indexOf('\t')
672    const [, , blob] = entry.slice(0, tab).split(' ')
673    if (tab > 0 && blob) blobs.set(entry.slice(tab + 1), blob)
674  }
675
676  return blobs
677}
678
679function isSameContent(a: Map<string, string>, b: Map<string, string>, files: string[]): boolean {
680  return files.every(f => a.get(f) === b.get(f))
681}
682
683/**
684 * Whether the files `sha` changed (since its merge-base with a remote branch) hold, at that branch's tip or at
685 * one of its commits since the base that touched them, exactly what they hold in `sha`: its content reached
686 * the remote, as itself, rebased, cherry-picked or as the last of several commits squashed into one.
687 */
688async function isContentOnRemote(git: Git, sha: string, remotes: string[]): Promise<boolean> {
689  for (const remote of remotes) {
690    const base = await git(['merge-base', sha, remote])
691    if (!base) continue
692    const files = ((await git(['-c', 'core.quotepath=off', 'diff', '--name-only', '-z', base, sha])) ?? '').split('\0').filter(Boolean)
693    // Nothing of its own beyond the base the remote already holds.
694    if (files.length === 0) return true
695    if (files.length > CONTENT_FILES) continue
696    const mine = await blobsOf(git, sha, files)
697    if (!mine) continue
698    const touched = ((await git(['log', '--format=%H', `-${CONTENT_COMMITS}`, `${base}..${remote}`, '--', ...files])) ?? '').split('\n').filter(Boolean)
699    for (const commit of [remote, ...touched]) {
700      const theirs = await blobsOf(git, commit, files)
701      if (theirs && isSameContent(mine, theirs, files)) return true
702    }
703  }
704
705  return false
706}
707
708/**
709 * Whether what `sha` changed has reached a remote branch: the commit itself is on one; or its content is
710 * (isContentOnRemote); or the content of one of its descendants on a local branch is, newest first. The last
711 * covers an earlier commit of several squashed into one: its lines may have been edited again by the later
712 * ones, so only the squash's last commit matches the pushed content, and that commit holds this one's work.
713 *
714 * Anything else reads as not pushed, even when the remote holds the work in another form (only part of it,
715 * or edited on top before any match): the safe way, since a work item wrongly kept undelivered shows and can
716 * be closed by hand, while one wrongly delivered disappears from the board.
717 */
718export async function isPushedContent(git: Git, sha: string): Promise<boolean> {
719  if (await git(['branch', '-r', '--contains', sha])) return true
720  const refs = (await git(['for-each-ref', '--format=%(refname:short)', 'refs/remotes'])) ?? ''
721  const remotes = refs.split('\n').map(r => r.trim()).filter(r => r && !r.endsWith('/HEAD') && r.includes('/'))
722  if (remotes.length === 0) return false
723  if (await isContentOnRemote(git, sha, remotes)) return true
724  const tips = ((await git(['for-each-ref', '--contains', sha, '--format=%(objectname)', 'refs/heads'])) ?? '').split('\n').filter(Boolean)
725  const tried = new Set<string>()
726  for (const tip of tips.slice(0, DESCENDANT_TIPS)) {
727    const after = ((await git(['rev-list', '--ancestry-path', `-${DESCENDANTS}`, `${sha}..${tip}`])) ?? '').split('\n').filter(Boolean)
728    for (const later of after) {
729      if (tried.has(later)) continue
730      tried.add(later)
731      if (await isContentOnRemote(git, later, remotes)) return true
732    }
733  }
734
735  return false
736}
737
738/** What a session still has to deliver, as people read it. */
739export function deliveryText(kinds: DeliveryKind[]): string {
740  return kinds.map(k => t(`delivery.${k}`)).join(t('list.joiner'))
741}
742
743/**
744 * The recap, when it says something is left: work not done, verified or tested (read like every report, quotes,
745 * rules and someone else's work aside), or delivery left that the latest words still leave undone, as the
746 * board reads it: gone once git shows the one push left done (the same check as *Not released*).
747 */
748export function caveatOf(track: Track | undefined, push?: PushCheck): string | null {
749  const recap = track?.recap
750  if (!recap) return null
751  if (CAVEAT.test(reportedText(recap.replace(NOTHING_LEFT, '')))) return recap
752
753  return leftOf(recap, push).length > 0 && isUndelivered(latestWords(track), push) ? recap : null
754}
755
756/**
757 * What the CI the words left running comes to, as GitHub last answered for those same words:
758 * `ci` while it runs or cannot be checked, `failed` once it failed, null when it passed or none is running.
759 */
760function ciStatus(text: string, ci: CiCheck | undefined): Status | null {
761  if (!isCiPending(text)) return null
762  const known = ci?.words === text ? ci.state : 'unknown'
763  if (known === 'success') return null
764
765  return known === 'failure' ? 'failed' : 'ci'
766}
767
768export function statusOf(one: Session, track: Track | undefined, word: LastWord | undefined, now: number, ci?: CiCheck, push?: PushCheck): Status {
769  if (one.isRunning) return 'running'
770  const minutes = (now - (Date.parse(one.lastActivityAt) || 0)) / 60000
771  if (track) {
772    if (track.ask || /[??]\s*$/.test(track.lastAnswer)) return 'asking'
773    // CI that has not finished is never done, however long ago the session stopped.
774    const pending = ciStatus(latestWords(track), ci)
775    if (pending) return pending
776    // A session that ended its turn to wait on its own background work has not stopped midway,
777    // whatever its task list still holds: while the work runs (its notification not yet in), however long that takes.
778    if (backgroundOf(track, now).length > 0) return 'waiting'
779    // Words alone say the same for a while: a session that never came back has stopped after all.
780    if (WAITING.test(reportedText(track.lastAnswer)) && minutes < WAITING_MINUTES) return 'waiting'
781    if (openSteps(track).length > 0) return 'open'
782    // Not committed, pushed or released, however ticked its task list (wrap_up ticks every step).
783    if (isUndelivered(latestWords(track), push)) return 'unreleased'
784  } else {
785    if (word?.isAsking) return 'asking'
786    const pending = word ? ciStatus(word.said, ci) : null
787    if (pending) return pending
788    if (word?.isWaiting && minutes < WAITING_MINUTES) return 'waiting'
789    if (word && isUndelivered(word.said, push)) return 'unreleased'
790  }
791
792  return minutes > STALE_DAYS * 24 * 60 ? 'stale' : 'done'
793}
794
795export function ago(iso: string | number, now: number): string {
796  const at = typeof iso === 'number' ? iso : Date.parse(iso)
797  if (!Number.isFinite(at) || at === 0) return t('time.unknown')
798  const min = Math.max(0, Math.round((now - at) / 60000))
799  if (min < 1) return t('time.now')
800  if (min < 60) return t('time.minutes', { n: min })
801  const hours = Math.round(min / 60)
802  if (hours < 24) return t('time.hours', { n: hours })
803
804  return t('time.days', { n: Math.round(hours / 24) })
805}
806
807export function clip(text: string, max: number): string {
808  const flat = text.replace(/\s+/g, ' ').trim()
809
810  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
811}
812
813/** Parses list_sessions' JSON text; archived sessions are left out. */
814export function parseSessions(text: string): Session[] {
815  const rows: unknown = JSON.parse(text)
816  if (!Array.isArray(rows)) throw new Error(t('error.notArray'))
817
818  return rows.flatMap(row => {
819    if (typeof row !== 'object' || row === null) return []
820    const o = row as Record<string, unknown>
821    if (typeof o.sessionId !== 'string' || o.isArchived === true) return []
822
823    return [
824      {
825        id: o.sessionId,
826        title: typeof o.title === 'string' && o.title ? o.title : t('session.untitled'),
827        cwd: typeof o.cwd === 'string' ? o.cwd : '',
828        isRunning: o.isRunning === true,
829        lastActivityAt: typeof o.lastActivityAt === 'string' ? o.lastActivityAt : '',
830        isPinned: o.pinned === true,
831        hasOpenPr: o.prState === 'OPEN',
832      },
833    ]
834  })
835}
836
837/** Reads list_events' plaintext into the session's last word. */
838export function parseLastWord(text: string, activity: string): LastWord {
839  const marks: { role: string; end: number; at: number }[] = []
840  const re = /^\[(user|assistant|result)\] /gm
841  for (let m = re.exec(text); m; m = re.exec(text)) marks.push({ role: m[1] as string, at: m.index, end: m.index + m[0].length })
842  const entries = marks.map((mark, i) => ({ role: mark.role, body: text.slice(mark.end, marks[i + 1]?.at ?? text.length).trim() }))
843
844  const said = entries.filter(e => e.role === 'assistant' && !/^\(called /.test(e.body)).at(-1)
845  const last = entries.filter(e => e.role !== 'result').at(-1)
846  const isLast = !!said && last === said
847  const isAsking = isLast && /[??]\s*$/.test(said.body)
848
849  return {
850    said: said ? clip(said.body, 140) : '',
851    isAsking,
852    isWaiting: isLast && !isAsking && WAITING.test(reportedText(said.body)),
853    activity,
854    error: null,
855  }
856}
857
858function isStatus(v: unknown): v is Step['status'] {
859  return v === 'pending' || v === 'in_progress' || v === 'completed'
860}
861
862/** The task list after one update_plan call: a whole list, `advance`, `done` or `start`. */
863export function applyPlan(plan: Step[], input: Record<string, unknown>): Step[] {
864  if (Array.isArray(input.items)) {
865    return input.items.flatMap(item => {
866      const o = item as Record<string, unknown>
867      if (typeof o?.content !== 'string') return []
868
869      return [{ content: o.content, status: isStatus(o.status) ? o.status : 'pending' }]
870    })
871  }
872  const next = plan.map(s => ({ ...s }))
873  if (input.advance === true) {
874    const at = next.findIndex(s => s.status === 'in_progress')
875    if (at >= 0) next[at]!.status = 'completed'
876    const following = next.findIndex((s, i) => i > at && s.status === 'pending')
877    if (following >= 0) next[following]!.status = 'in_progress'
878  }
879  if (Array.isArray(input.done)) {
880    for (const n of input.done) if (typeof n === 'number' && next[n - 1]) next[n - 1]!.status = 'completed'
881  }
882  if (typeof input.start === 'number' && next[input.start - 1]) {
883    for (const s of next) if (s.status === 'in_progress') s.status = 'pending'
884    next[input.start - 1]!.status = 'in_progress'
885  }
886
887  return next
888}
889
890/** TodoWrite's list, for a session that keeps its tasks there (a terminal session). */
891export function planFromTodos(todos: unknown): Step[] {
892  if (!Array.isArray(todos)) return []
893
894  return todos.flatMap(t => {
895    const o = t as Record<string, unknown>
896    if (typeof o?.content !== 'string') return []
897
898    return [{ content: o.content, status: isStatus(o.status) ? o.status : 'pending' }]
899  })
900}
901
902const STEP_MARK: Record<Step['status'], string> = { completed: '[x]', in_progress: '[>]', pending: '[ ]' }
903
904export type BoardInput = {
905  project: { name: string; path: string }
906  sessions: Session[]
907  /** Every listed (unarchived) session, of any project: an owner elsewhere is still there. */
908  everyone?: Session[]
909  tracks: Record<string, Track>
910  words: Record<string, LastWord>
911  ci?: Record<string, CiCheck>
912  push?: Record<string, PushCheck>
913  ledger: LedgerItem[]
914  now: number
915  isIncludingDone: boolean
916}
917
918/** The board as the PM model reads it: what needs attention first, then every session. */
919export function boardText(input: BoardInput): string {
920  const { project, sessions, words, ledger, now } = input
921  const everyone = input.everyone ?? sessions
922  const tracks = liveTracks(everyone, input.tracks, words)
923  const ci = input.ci ?? {}
924  const push = input.push ?? {}
925  const rows = sessions
926    .map(one => ({ one, track: tracks[one.id], word: words[one.id], status: statusOf(one, tracks[one.id], words[one.id], now, ci[one.id], push[one.id]) }))
927    .sort((a, b) => STATUS[a.status].rank - STATUS[b.status].rank)
928  const lines: string[] = []
929  lines.push(t('board.title', { project: project.name }), t('board.path', { path: project.path }))
930  const count = (s: Status) => rows.filter(r => r.status === s).length
931  lines.push(
932    t('board.counts', {
933      total: rows.length,
934      asking: count('asking'),
935      failed: count('failed'),
936      ci: count('ci'),
937      unreleased: count('unreleased'),
938      open: count('open'),
939      running: count('running'),
940      waiting: count('waiting'),
941      done: count('done'),
942      stale: count('stale'),
943    }),
944  )
945
946  const counted = ledger.filter(item => item.status !== 'dropped')
947  if (counted.length) lines.push(t('board.progress', { done: counted.filter(i => i.status === 'done').length, total: counted.length }))
948
949  const attention: string[] = []
950  for (const r of rows) {
951    const name = t('board.name', { title: r.one.title, id: r.one.id })
952    if (r.status === 'asking') attention.push(t('board.attention.asking', { name, said: clip(r.track?.ask ?? r.track?.lastAnswer ?? r.word?.said ?? '', 200) }))
953    if (r.status === 'open') {
954      const left = openSteps(r.track)
955      attention.push(t('board.attention.open', { name, n: left.length, steps: left.map(s => s.content).join(t('list.steps')) }))
956    }
957    if (r.status === 'ci') attention.push(t('board.attention.ci', { name, said: clip(ciWords(r.track, r.word), 200) }))
958    if (r.status === 'unreleased') {
959      const words = ciWords(r.track, r.word)
960      attention.push(t('board.attention.unreleased', { name, kinds: deliveryText(leftOf(words, push[r.one.id])), said: clip(words, 200) }))
961    }
962    if (r.status === 'failed') {
963      const target = ci[r.one.id]?.target || t('board.attention.failedTarget')
964      attention.push(t('board.attention.failed', { name, target, said: clip(ciWords(r.track, r.word), 200) }))
965    }
966    const caveat = caveatOf(r.track, push[r.one.id])
967    if (caveat && !['running', 'ci', 'failed', 'unreleased'].includes(r.status)) attention.push(t('board.attention.caveat', { name, said: clip(caveat, 200) }))
968    if (r.one.hasOpenPr && r.status !== 'running') attention.push(t('board.attention.pr', { name }))
969  }
970  const openLedger = ledger.filter(item => item.status === 'open')
971  const known = new Set(everyone.map(s => s.id))
972  for (const item of openLedger) {
973    if (!item.owner) attention.push(t('board.attention.unassigned', { code: itemCode(item.id), text: item.text }))
974    else if (!known.has(item.owner)) attention.push(t('board.attention.lost', { code: itemCode(item.id), text: item.text, owner: item.owner }))
975  }
976  const inProject = new Set(sessions.map(s => s.id))
977  /** An owner as the ledger shows it: its title and status, and whether it works from another project's folder. */
978  const ownerText = (id: string | null) => {
979    if (!id) return t('board.owner.unassigned')
980    const one = everyone.find(s => s.id === id)
981    if (!one) return t('board.owner.gone', { id })
982    const status = statusOf(one, tracks[id], words[id], now, ci[id], push[id])
983
984    return t('board.owner.status', { title: one.title, tag: STATUS[status].tag, elsewhere: inProject.has(id) ? '' : t('board.owner.elsewhere') })
985  }
986  lines.push('', t('board.section.attention'), attention.length ? attention.join('\n') : t('board.none'))
987
988  lines.push('', t('board.section.items'))
989  const shown = input.isIncludingDone ? ledger : openLedger
990  if (shown.length === 0) lines.push(openLedger.length === 0 && ledger.length > 0 ? t('board.allDone') : t('board.none'))
991  for (const item of shown) {
992    const note = item.note ? t('board.itemNote', { note: item.note }) : ''
993    lines.push(t('board.item', { code: itemCode(item.id), id: item.id, status: item.status, text: item.text, owner: ownerText(item.owner), note }))
994  }
995
996  lines.push('', '## Sessions')
997  let listed = 0
998  for (const r of rows) {
999    const isOwner = openLedger.some(item => item.owner === r.one.id)
1000    if (!input.isIncludingDone && !isActive(r.status) && !caveatOf(r.track, push[r.one.id]) && !r.one.hasOpenPr && !isOwner) continue
1001    listed += 1
1002    const flags = [r.one.hasOpenPr ? t('board.flag.pr') : '', r.one.isPinned ? t('board.flag.pinned') : ''].filter(Boolean).join(t('list.joiner'))
1003    lines.push(`### [${STATUS[r.status].tag}] ${r.one.title} (${r.one.id})`)
1004    lines.push(t('board.lastActive', { ago: ago(r.one.lastActivityAt, now), flags: flags ? t('board.flags', { flags }) : '' }))
1005    if (r.track) {
1006      if (r.track.plan.length) {
1007        const steps = r.track.plan.map(s => `${STEP_MARK[s.status]} ${s.content}`).join(' / ')
1008        lines.push(t('board.tasks', { done: r.track.plan.filter(s => s.status === 'completed').length, total: r.track.plan.length, steps }))
1009      }
1010      if (r.track.lastPrompt) lines.push(t('board.prompt', { text: clip(r.track.lastPrompt, 160) }))
1011      if (r.track.recap) lines.push(t('board.recap', { text: clip(r.track.recap, 300) }))
1012      else if (r.track.lastAnswer) lines.push(t('board.answer', { text: clip(r.track.lastAnswer, 300) }))
1013      if (r.track.ask) lines.push(t('board.ask', { text: r.track.ask }))
1014      const background = backgroundOf(r.track, now)
1015      if (background.length) {
1016        const tasks = background.map(task => t('board.backgroundTask', { label: clip(task.label, 80), ago: ago(task.at, now) })).join(t('list.steps'))
1017        lines.push(t('board.background', { n: background.length, tasks }))
1018      }
1019    } else {
1020      const said = r.word?.error ? t('board.readFailed', { error: r.word.error }) : (r.word?.said ?? t('board.reading'))
1021      lines.push(t('board.untracked', { said }))
1022    }
1023  }
1024  const hidden = rows.length - listed
1025  if (hidden > 0) lines.push('', t('board.hidden', { n: hidden }))
1026
1027  return lines.join('\n')
1028}
1029
1030export function emptyProject(key: string, name: string, path: string): ProjectFile {
1031  return { key, name, path, pmId: null, isDismissed: false, ledger: [], nextId: 1 }
1032}
1033
hooks/actions.ts 1031 lines
1// What the PM does: who this session is, reading the other sessions, the
2// files each session and project keep, claiming a project, and the PM's tools.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, McpToolResult } from 'claude-code'
6
7import type { CiCheck, LastWord, LedgerItem, Me, Notice, ProjectFile, PushCheck, Scope, Session, Sessions, Track } from '../types'
8import {
9  boardText,
10  betterCi,
11  ciTargets,
12  ciVerdict,
13  ciWords,
14  clip,
15  deliveryText,
16  emptyProject,
17  groupProjects,
18  hasReport,
19  isBehind,
20  isCiPending,
21  isPushedContent,
22  isScratch,
23  itemCode,
24  keyFile,
25  latestWords,
26  leftOf,
27  liveTracks,
28  openSteps,
29  ownCommitOf,
30  parseLastWord,
31  parseSessions,
32  projectKey,
33  projectName,
34  projectPath,
35  statusOf,
36  undeliveredOf,
37} from './sessions'
38import type { CiRun, Project, Status } from './sessions'
39import { t } from './messages'
40
41// The engine reads each module's state refs from its own source: these name the same values as the other modules'.
42const me = atom({ plugin: 'project-pm', key: 'me' } as const, null as Me | null)
43const sessions = atom({ plugin: 'project-pm', key: 'sessions' } as const, { list: [], fetchedAt: 0, error: null } as Sessions)
44const tracks = atom({ plugin: 'project-pm', key: 'tracks' } as const, {} as Record<string, Track>)
45const lastWords = atom({ plugin: 'project-pm', key: 'lastWords' } as const, {} as Record<string, LastWord>)
46const ciChecks = atom({ plugin: 'project-pm', key: 'ciChecks' } as const, {} as Record<string, CiCheck>)
47const pushChecks = atom({ plugin: 'project-pm', key: 'pushChecks' } as const, {} as Record<string, PushCheck>)
48const projects = atom({ plugin: 'project-pm', key: 'projects' } as const, {} as Record<string, ProjectFile>)
49const confirm = atom({ plugin: 'project-pm', key: 'confirm' } as const, null as string | null)
50const busy = atom({ plugin: 'project-pm', key: 'busy' } as const, null as string | null)
51const notice = atom({ plugin: 'project-pm', key: 'notice' } as const, null as Notice | null)
52const isBandHidden = atom({ plugin: 'project-pm', key: 'isBandHidden' } as const, false)
53const scope = atom({ plugin: 'project-pm', key: 'scope' } as const, 'project' as Scope)
54
55export const MGMT = 'ccd_session_mgmt'
56export const STARTER = 'ccd_session'
57export const PANE = 'project-pm'
58export const POLL_MS = 30_000
59export const STUCK_MS = 20_000
60export const WORDS_PER_ROUND = 8
61/** GitHub lookups per refresh, and how long each command may take: the board waits on them. */
62export const CI_CHECKS_PER_ROUND = 3
63export const GIT_TIMEOUT_MS = 4_000
64export const GH_TIMEOUT_MS = 8_000
65/**
66 * How soon an answer is asked again: a pass stands, a failure may be re-run, a running CI moves on. Unknown (no run
67 * GitHub knows of, no gh, no network) seldom changes by the minute: asked every minute, it cost a `gh` or three each
68 * time for as long as the words stood.
69 */
70const CI_RECHECK_MS: Record<CiCheck['state'], number> = { success: Infinity, failure: 5 * 60_000, pending: 60_000, unknown: 10 * 60_000 }
71/** Put in a new PM session's brief: the session that reads it claims its project. */
72export const CLAIM = '[project-pm:claim]'
73export const BOARD_TOOL = 'mcp__project-pm__board'
74export const LEDGER_TOOL = 'mcp__project-pm__ledger'
75
76export function textOf(result: McpToolResult): string {
77  return result.content
78    .map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
79    .join('\n')
80    .trim()
81}
82
83export function reason(err: unknown): string {
84  return err instanceof Error ? err.message : String(err)
85}
86
87// ── Files: one per session (written by that session alone) and one per project ──
88
89export async function dataDir($: EngineInterface): Promise<string> {
90  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
91
92  return `${home.replace(/\\/g, '/')}/.claude/project-pm`
93}
94
95export async function readJson<T>($: EngineInterface, path: string): Promise<T | null> {
96  try {
97    if (!(await $.fs.exists(path))) return null
98
99    return JSON.parse(await $.fs.read(path)) as T
100  } catch {
101    return null
102  }
103}
104
105export async function loadProject($: EngineInterface, key: string, name: string, path: string): Promise<ProjectFile> {
106  const file = await readJson<ProjectFile>($, `${await dataDir($)}/projects/${keyFile(key)}.json`)
107
108  return file ? { ...emptyProject(key, name, path), ...file } : emptyProject(key, name, path)
109}
110
111export async function saveProject($: EngineInterface, project: ProjectFile) {
112  await $.fs.write(`${await dataDir($)}/projects/${keyFile(project.key)}.json`, JSON.stringify(project, null, 2))
113  await update($, projects, all => ({ ...all, [project.key]: project }))
114}
115
116/** This session's own track, kept in the module and mirrored to its file. */
117let mine: Track | null = null
118
119export function getMine(): Track | null {
120  return mine
121}
122
123export async function saveMine($: EngineInterface) {
124  if (!mine) return
125  const track = mine
126  try {
127    await $.fs.write(`${await dataDir($)}/sessions/${track.id}.json`, JSON.stringify(track))
128  } catch (err) {
129    $.ui.log(t('log.cannotWrite', { reason: reason(err) }))
130  }
131}
132
133/** When this session last asked the app who it is, so an unknown session asks again at most every 30 s. */
134let identifiedAt = 0
135
136export async function changeMine($: EngineInterface, change: (track: Track) => Track) {
137  if (!mine) {
138    // A session the app did not know yet at its start (or a module that just reloaded) asks again.
139    const now = await $.clock.now()
140    if (now - identifiedAt >= 30_000) {
141      identifiedAt = now
142      try {
143        await identify($)
144      } catch (err) {
145        $.ui.log(`project-pm: ${reason(err)}`)
146      }
147    }
148  }
149  if (!mine) return
150  mine = change(mine)
151  await saveMine($)
152}
153
154// ── Who this session is ──
155
156/**
157 * Loads this session's track into the module. `me` is the host's and outlives a
158 * reload of the module; `mine` does not, so each reload loads it again.
159 */
160async function loadMine($: EngineInterface, id: string, title: string, cwd: string) {
161  if (mine?.id === id) return
162  const kept = await readJson<Track>($, `${await dataDir($)}/sessions/${id}.json`)
163  const base = kept ?? { id, title, cwd, plan: [], recap: null, ask: null, lastPrompt: '', lastAnswer: '', turnAt: 0, planAt: 0 }
164  mine = { ...base, title: title || base.title, cwd: cwd || base.cwd }
165  await saveMine($)
166}
167
168export async function identify($: EngineInterface): Promise<Me | null> {
169  const known = await read($, me)
170  if (known?.id) {
171    await loadMine($, known.id, known.title, mine?.cwd ?? known.projectPath)
172
173    return known
174  }
175  let id: string | null = null
176  let title = ''
177  let cwd = await $.session.cwd()
178  try {
179    const result = await $.mcp.call(MGMT, 'get_session', { session_id: 'self' })
180    if (!result.isError) {
181      const o = JSON.parse(textOf(result)) as Record<string, unknown>
182      if (typeof o.sessionId === 'string') id = o.sessionId
183      if (typeof o.title === 'string') title = o.title
184      if (typeof o.cwd === 'string' && o.cwd) cwd = o.cwd
185    }
186  } catch {
187    // Outside the desktop app there is no session id: this session is not tracked.
188  }
189  if (id) await rememberTranscript($, id)
190  // A session the app has just resumed gets its tools only once the person looks at it: its transcript says who it is.
191  else id = await transcriptOwner($)
192  const key = projectKey(cwd)
193  const project = await loadProject($, key, projectName(cwd), projectPath(cwd))
194  if (!sameJson((await read($, projects))[key], project)) await update($, projects, all => ({ ...all, [key]: project }))
195  const found: Me = {
196    id,
197    title,
198    projectKey: key,
199    projectName: projectName(cwd),
200    projectPath: projectPath(cwd),
201    isScratch: isScratch(cwd),
202    isPm: !!id && project.pmId === id,
203  }
204  if (!sameJson(known, found)) await update($, me, () => found)
205  if (id) await loadMine($, id, title, cwd)
206
207  return found
208}
209
210/** Whether two values hold the same data: a write of the same value would redraw the board for nothing. */
211export function sameJson(a: unknown, b: unknown): boolean {
212  return JSON.stringify(a) === JSON.stringify(b)
213}
214
215/** How many transcripts the file of desktop ids keeps, the latest last. */
216const TRANSCRIPTS_KEPT = 300
217
218/** The file that says which desktop session each transcript (`$.session.id()`) belongs to. */
219async function transcriptsFile($: EngineInterface): Promise<string> {
220  return `${await dataDir($)}/transcripts.json`
221}
222
223/** Keeps this process's transcript under its desktop id, so a later process of the same session knows it at once. */
224async function rememberTranscript($: EngineInterface, id: string) {
225  try {
226    const transcript = await $.session.id()
227    const path = await transcriptsFile($)
228    const all = (await readJson<Record<string, string>>($, path)) ?? {}
229    if (all[transcript] === id) return
230    delete all[transcript]
231    const kept = Object.entries({ ...all, [transcript]: id }).slice(-TRANSCRIPTS_KEPT)
232    await $.fs.write(path, JSON.stringify(Object.fromEntries(kept)))
233  } catch {
234    // Knowing it sooner is a nicety: the app's tools still say who this is.
235  }
236}
237
238/** The desktop id this process's transcript was last seen under, or null. */
239async function transcriptOwner($: EngineInterface): Promise<string | null> {
240  try {
241    return (await readJson<Record<string, string>>($, await transcriptsFile($)))?.[await $.session.id()] ?? null
242  } catch {
243    return null
244  }
245}
246
247// ── Reading the other sessions ──
248
249let fetchingSince = 0
250const trackSeen = new Map<string, number>()
251/** Each session's status at the last refresh, so the PM hears about a change once. */
252let statusesBefore: Map<string, Status> | null = null
253
254export async function readLastWord($: EngineInterface, one: Session): Promise<LastWord> {
255  try {
256    const result = await $.mcp.call(MGMT, 'list_events', { session_id: one.id, limit: 12 })
257    const text = textOf(result)
258    if (result.isError) return { said: '', isAsking: false, isWaiting: false, activity: one.lastActivityAt, error: text || t('error.noTranscript') }
259
260    return parseLastWord(text, one.lastActivityAt)
261  } catch (err) {
262    return { said: '', isAsking: false, isWaiting: false, activity: one.lastActivityAt, error: reason(err) }
263  }
264}
265
266export async function readTracks($: EngineInterface, list: Session[]) {
267  const dir = `${await dataDir($)}/sessions`
268  let entries: { name: string; mtimeMs: number }[] = []
269  try {
270    entries = (await $.fs.exists(dir)) ? await $.fs.list(dir) : []
271  } catch {
272    entries = []
273  }
274  const wanted = new Set(list.map(s => s.id))
275  const fresh: Record<string, Track> = {}
276  for (const entry of entries) {
277    const id = entry.name.replace(/\.json$/, '')
278    if (!wanted.has(id) || trackSeen.get(id) === entry.mtimeMs) continue
279    const track = await readJson<Track>($, `${dir}/${entry.name}`)
280    // An empty track would hide the transcript's last word: leave it out until it holds a report.
281    if (hasReport(track)) {
282      fresh[id] = track
283      trackSeen.set(id, entry.mtimeMs)
284    }
285  }
286  const held = await read($, tracks)
287  const isStale = Object.entries(held).some(([id, track]) => !wanted.has(id) || !hasReport(track))
288  if (Object.keys(fresh).length === 0 && !isStale) return
289  await update($, tracks, all => {
290    // Held values outlive a reload of the module: an empty track kept from before goes too.
291    const kept = Object.fromEntries(Object.entries(all).filter(([id, track]) => wanted.has(id) && hasReport(track)))
292
293    return { ...kept, ...fresh }
294  })
295}
296
297/** Reads the last word of sessions untracked, or tracked but behind, that did something since they were last read. */
298export async function catchUp($: EngineInterface, list: Session[], asked = list) {
299  const known = await read($, lastWords)
300  const tracked = await read($, tracks)
301  const isUnread = (one: Session) => !tracked[one.id] || isBehind(one, tracked[one.id]!)
302  const stale = asked.filter(one => isUnread(one) && known[one.id]?.activity !== one.lastActivityAt).slice(0, WORDS_PER_ROUND)
303  if (stale.length === 0) return
304  const fresh: Record<string, LastWord> = {}
305  for (const one of stale) fresh[one.id] = await readLastWord($, one)
306  await update($, lastWords, all => {
307    const kept = Object.fromEntries(Object.entries(all).filter(([id]) => list.some(s => s.id === id)))
308
309    return { ...kept, ...fresh }
310  })
311}
312
313// ── Asking GitHub about CI a session left running ──
314
315async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs: number): Promise<string | null> {
316  try {
317    const out = await $.process.run(argv, { cwd, timeoutMs })
318
319    return out.exitCode === 0 ? out.stdout.trim() : null
320  } catch {
321    // No such folder, no git or gh, or too slow: GitHub stays unasked.
322    return null
323  }
324}
325
326/**
327 * What GitHub says about the CI `text` left running in `cwd`: the run it names, else the commit it
328 * names, else the folder's HEAD when that commit is no newer than the words (`saidAt`).
329 */
330/** How many named runs and commits one check tries before it falls back to the folder's HEAD. */
331const CI_CANDIDATES = 3
332
333/** A named run's answer and the commit it ran on (a failed or called-off run hands its commit over). */
334async function askRun($: EngineInterface, cwd: string, id: string): Promise<{ state: CiCheck['state']; sha: string | null }> {
335  const out = await run($, ['gh', 'run', 'view', id, '--json', 'status,conclusion,workflowName,headSha'], cwd, GH_TIMEOUT_MS)
336  try {
337    const one = out === null ? null : (JSON.parse(out) as CiRun & { headSha?: string })
338
339    return one ? { state: ciVerdict([one]), sha: typeof one.headSha === 'string' ? one.headSha : null } : { state: 'unknown', sha: null }
340  } catch {
341    return { state: 'unknown', sha: null }
342  }
343}
344
345async function askCommit($: EngineInterface, cwd: string, sha: string): Promise<CiCheck['state']> {
346  const out = await run($, ['gh', 'run', 'list', '--commit', sha, '--limit', '20', '--json', 'status,conclusion,workflowName'], cwd, GH_TIMEOUT_MS)
347  try {
348    const runs: unknown = out === null ? null : JSON.parse(out)
349
350    return Array.isArray(runs) ? ciVerdict(runs as CiRun[]) : 'unknown'
351  } catch {
352    return 'unknown'
353  }
354}
355
356/** How many remote branch tips holding a commit are asked about with it. */
357const CI_TIPS = 2
358
359/**
360 * A commit's answer, with that of each remote branch tip that holds it: newer work on the same branch
361 * (a fix pushed after it, someone's later commit) answers for it too, the better answer standing (betterCi).
362 */
363async function askCommitOrNewer($: EngineInterface, cwd: string, sha: string): Promise<{ target: string; state: CiCheck['state'] }> {
364  let best = { target: `commit ${sha.slice(0, 7)}`, state: await askCommit($, cwd, sha) }
365  if (best.state === 'success') return best
366  const tips = ((await run($, ['git', 'for-each-ref', '--contains', sha, '--format=%(objectname)', 'refs/remotes'], cwd, GIT_TIMEOUT_MS)) ?? '')
367    .split('\n')
368    .filter((tip, i, all) => tip && tip !== sha && all.indexOf(tip) === i)
369  for (const tip of tips.slice(0, CI_TIPS)) {
370    const state = await askCommit($, cwd, tip)
371    if (betterCi(state, best.state) === state && state !== best.state) best = { target: `commit ${tip.slice(0, 7)}`, state }
372  }
373
374  return best
375}
376
377/**
378 * What GitHub says about the CI `text` left running in `cwd`.
379 *
380 * A named run that passed or still runs answers. Otherwise the commits the words name (likeliest first,
381 * only those in this repository; a failed or called-off run's own commit among them) are asked, each with
382 * the remote branch tips that hold it, skipping one that another named commit holds: when the words tell
383 * of commit A and then its fix B, B is the newer word. When none answers, the folder's HEAD, when committed
384 * no later than the words.
385 */
386export async function askCi($: EngineInterface, cwd: string, text: string, saidAt: number): Promise<Omit<CiCheck, 'words' | 'checkedAt'>> {
387  if (!cwd) return { target: '', state: 'unknown' }
388  const named = ciTargets(text)
389  let first = ''
390  const handed: string[] = []
391  for (const id of named.runs.slice(0, CI_CANDIDATES)) {
392    const { state, sha } = await askRun($, cwd, id)
393    if (state === 'success' || state === 'pending') return { target: `run ${id}`, state }
394    if (sha) handed.push(sha)
395    else if (state !== 'unknown') return { target: `run ${id}`, state }
396    first ||= `run ${id}`
397  }
398  const commits: string[] = []
399  for (const short of [...handed, ...named.shas.slice(0, CI_CANDIDATES)]) {
400    const sha = await run($, ['git', 'rev-parse', '--verify', '--quiet', `${short}^{commit}`], cwd, GIT_TIMEOUT_MS)
401    if (sha && !commits.includes(sha)) commits.push(sha)
402  }
403  // `merge-base --is-ancestor` answers by its exit code alone: run's '' is yes, null is no.
404  const isHeldBy = async (older: string, newer: string) => (await run($, ['git', 'merge-base', '--is-ancestor', older, newer], cwd, GIT_TIMEOUT_MS)) !== null
405  const latest: string[] = []
406  for (const sha of commits) {
407    let isOlder = false
408    for (const other of commits) if (other !== sha && (await isHeldBy(sha, other))) isOlder = true
409    if (!isOlder) latest.push(sha)
410  }
411  let failed: { target: string; state: CiCheck['state'] } | null = null
412  for (const sha of latest) {
413    const answer = await askCommitOrNewer($, cwd, sha)
414    if (answer.state === 'success' || answer.state === 'pending') return answer
415    if (answer.state === 'failure') failed ??= answer
416    first ||= answer.target
417  }
418  if (failed) return failed
419  const head = await run($, ['git', 'log', '-1', '--format=%H %ct'], cwd, GIT_TIMEOUT_MS)
420  const [full, at] = head?.split(' ') ?? []
421  // A HEAD committed after the words is not the commit they waited on.
422  if (full && Number(at) * 1000 <= saidAt) {
423    const state = await askCommit($, cwd, full)
424    if (state !== 'unknown') return { target: `commit ${full.slice(0, 7)}`, state }
425    first ||= `commit ${full.slice(0, 7)}`
426  }
427
428  return { target: first, state: 'unknown' }
429}
430
431/** The check standing for these words, asking GitHub again when the last answer is too old to stand. */
432export async function checkCi($: EngineInterface, id: string, cwd: string, text: string, saidAt: number): Promise<CiCheck> {
433  const now = await $.clock.now()
434  const held = (await read($, ciChecks))[id]
435  if (held && held.words === text && now - held.checkedAt < CI_RECHECK_MS[held.state]) return held
436  const check: CiCheck = { ...(await askCi($, cwd, text, saidAt)), words: text, checkedAt: now }
437  await update($, ciChecks, all => ({ ...all, [id]: check }))
438
439  return check
440}
441
442let isCheckingCi = false
443
444/** Asks GitHub about the CI each idle session's latest words left running, a few per refresh. */
445export async function verifyCi($: EngineInterface, list: Session[], candidates = list) {
446  if (isCheckingCi) return
447  isCheckingCi = true
448  try {
449    const all = liveTracks(list, await read($, tracks), await read($, lastWords))
450    const words = await read($, lastWords)
451    const held = await read($, ciChecks)
452    const now = await $.clock.now()
453    let asked = 0
454    for (const one of candidates) {
455      if (one.isRunning) continue
456      const text = ciWords(all[one.id], words[one.id])
457      if (!isCiPending(text)) continue
458      const known = held[one.id]
459      if (known && known.words === text && now - known.checkedAt < CI_RECHECK_MS[known.state]) continue
460      if (asked >= CI_CHECKS_PER_ROUND) break
461      asked += 1
462      await checkCi($, one.id, one.cwd, text, all[one.id]?.turnAt || Date.parse(one.lastActivityAt) || now)
463    }
464    // Checks for sessions gone from the list go too.
465    if (Object.keys(held).some(id => !list.some(one => one.id === id))) {
466      await update($, ciChecks, c => Object.fromEntries(Object.entries(c).filter(([id]) => list.some(one => one.id === id))))
467    }
468  } finally {
469    isCheckingCi = false
470  }
471}
472
473// ── Asking git about the commit and push the words leave ──
474
475/**
476 * What git says of the commit and push the words leave, read-only in `cwd`, no fetch: whether the folder has
477 * uncommitted changes, and whether the commit the words name (or its content, rebased or squashed) or, with none
478 * named, a clean folder's HEAD is on a remote-tracking branch. Facts only settle: a dirty folder adds nothing the
479 * words did not say (a shared folder holds other sessions' changes).
480 */
481export async function checkPush($: EngineInterface, id: string, cwd: string, text: string): Promise<PushCheck | undefined> {
482  const left = undeliveredOf(text)
483  const own = ownCommitOf(text)
484  if (!cwd || (!own && !left?.kinds.some(kind => kind === 'commit' || kind === 'push'))) return undefined
485  const now = await $.clock.now()
486  const held = (await read($, pushChecks))[id]
487  // Settled stays settled; not yet is asked again after a minute.
488  if (held && held.words === text && ((held.isPushed && held.isClean) || now - held.checkedAt < 60_000)) return held
489  const git = (args: string[]) => run($, ['git', ...args], cwd, GIT_TIMEOUT_MS)
490  // No index refresh: another session's folder is read, never written.
491  const isClean = (await git(['--no-optional-locks', 'status', '--porcelain'])) === ''
492  const sha = own ?? left?.sha ?? null
493  // A hash of another repository, or none at all, says nothing here.
494  const isHere = !!sha && !!(await git(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]))
495  const isPushed = isHere ? await isPushedContent(git, sha) : !sha && isClean && !!(await git(['branch', '-r', '--contains', 'HEAD']))
496  const check: PushCheck = { words: text, sha: sha ?? '', isPushed, isClean, isOwn: isHere && sha === own, checkedAt: now }
497  await update($, pushChecks, all => ({ ...all, [id]: check }))
498
499  return check
500}
501
502/** Asks git about each idle session whose latest words leave a commit or a push. */
503export async function verifyPushes($: EngineInterface, list: Session[], asked = list) {
504  const words = await read($, lastWords)
505  const all = liveTracks(list, await read($, tracks), words)
506  for (const one of asked) {
507    if (!one.isRunning) await checkPush($, one.id, one.cwd, ciWords(all[one.id], words[one.id]))
508  }
509  const held = await read($, pushChecks)
510  if (Object.keys(held).some(id => !list.some(one => one.id === id))) {
511    await update($, pushChecks, c => Object.fromEntries(Object.entries(c).filter(([id]) => list.some(one => one.id === id))))
512  }
513}
514
515/** Each project file's mtime when it was last read: an unchanged file is not read again. */
516const projectSeen = new Map<string, number>()
517
518export async function readProjects($: EngineInterface, list: Session[]) {
519  const seen = new Map<string, { name: string; path: string }>()
520  for (const one of list) seen.set(projectKey(one.cwd), { name: projectName(one.cwd), path: projectPath(one.cwd) })
521  const self = await read($, me)
522  if (self) seen.set(self.projectKey, { name: self.projectName, path: self.projectPath })
523  const dir = `${await dataDir($)}/projects`
524  let mtimes = new Map<string, number>()
525  try {
526    if (await $.fs.exists(dir)) mtimes = new Map((await $.fs.list(dir)).map(entry => [entry.name, entry.mtimeMs]))
527  } catch {
528    // Read each one, as before.
529  }
530  const held = await read($, projects)
531  const loaded: Record<string, ProjectFile> = {}
532  for (const [key, at] of seen) {
533    const name = `${keyFile(key)}.json`
534    const mtime = mtimes.get(name)
535    const kept = held[key]
536    if (kept && mtime !== undefined && projectSeen.get(name) === mtime) loaded[key] = kept
537    // No file yet: a project nobody has claimed.
538    else if (mtimes.size > 0 && mtime === undefined) loaded[key] = emptyProject(key, at.name, at.path)
539    else {
540      loaded[key] = await loadProject($, key, at.name, at.path)
541      if (mtime !== undefined) projectSeen.set(name, mtime)
542    }
543  }
544  if (!sameJson(held, loaded)) await update($, projects, () => loaded)
545  if (self) {
546    const isPm = !!self.id && loaded[self.projectKey]?.pmId === self.id
547    if (isPm !== self.isPm) await update($, me, m => (m ? { ...m, isPm } : m))
548  }
549}
550
551/** Tells the PM, once, when one of its project's sessions starts waiting on the person or stops with steps left. */
552export async function announce($: EngineInterface) {
553  const self = await read($, me)
554  if (!self?.isPm) return
555  const state = await read($, sessions)
556  const words = await read($, lastWords)
557  const all = liveTracks(state.list, await read($, tracks), words)
558  const ci = await read($, ciChecks)
559  const push = await read($, pushChecks)
560  const now = await $.clock.now()
561  const now_ = new Map<string, Status>()
562  for (const one of state.list) {
563    if (projectKey(one.cwd) !== self.projectKey) continue
564    now_.set(one.id, statusOf(one, all[one.id], words[one.id], now, ci[one.id], push[one.id]))
565  }
566  const before = statusesBefore
567  statusesBefore = now_
568  if (!before) return
569  for (const [id, status] of now_) {
570    if (before.get(id) === status) continue
571    const title = state.list.find(s => s.id === id)?.title ?? id
572    if (status === 'asking') $.ui.toast(t('toast.asking', { title }))
573    if (status === 'failed') $.ui.toast(t('toast.failed', { title }))
574    if (status === 'unreleased') $.ui.toast(t('toast.unreleased', { title }))
575    if (status === 'open') $.ui.toast(t('toast.open', { title, n: openSteps(all[id]).length }))
576  }
577}
578
579/** How soon a refresh the app's tools could not answer tries again, and how many times: a resumed session gets them once the person looks at it. */
580const RETRY_MS = 3_000
581const RETRIES = 20
582let retriesLeft = RETRIES
583
584/**
585 * One round: the sessions the app lists, their files and last words, and what git and GitHub say about the
586 * work their words left. Only this session's project is asked about, unless `isWide` (the board tool's every
587 * project, a ledger owner's work) or the board shows every project: each PM asks about its own.
588 */
589export async function refresh($: EngineInterface, isForced = false, isWide = false) {
590  const self = await read($, me)
591  const pane = (await $.ui.panes()).find(one => one.id === PANE)
592  const isPaneOpen = pane !== undefined
593  if (!isForced && !self?.isPm && !isPaneOpen) return
594  await ensureTools($)
595  const started = Date.now()
596  if (fetchingSince && started - fetchingSince < STUCK_MS) return
597  fetchingSince = started
598  try {
599    const result = await $.mcp.call(MGMT, 'list_sessions', { limit: 100 })
600    const text = textOf(result)
601    if (result.isError) throw new Error(text || t('error.listFailed'))
602    const list = parseSessions(text)
603    retriesLeft = RETRIES
604    const held = await read($, sessions)
605    // The same list as before is no news: the board is drawn again for it only while it is on screen, where
606    // its times (synced, last active) move on.
607    const isNews = !sameJson(held.list, list) || held.error !== null || held.fetchedAt === 0
608    if (isNews || (pane?.isShown && pane.isPlaced)) await update($, sessions, () => ({ list, fetchedAt: started, error: null }))
609    const isEvery = isWide || !self || (isPaneOpen && (await read($, scope)) === 'all')
610    const asked = isEvery ? list : list.filter(one => projectKey(one.cwd) === self.projectKey)
611    await readTracks($, list)
612    await readProjects($, list)
613    await catchUp($, list, asked)
614    await verifyCi($, list, asked)
615    await verifyPushes($, list, asked)
616    await announce($)
617    await saveRound($)
618  } catch (err) {
619    const message = reason(err)
620    await update($, sessions, s => (s.error === message ? s : { ...s, error: message }))
621    if (retriesLeft > 0) {
622      retriesLeft -= 1
623      refreshSoon($, RETRY_MS)
624    }
625  } finally {
626    if (fetchingSince === started) fetchingSince = 0
627  }
628}
629
630// ── The last round, kept for the next process ──
631
632/** What a round found, so a new process of this session draws the board at once, not "syncing" until a round ends. */
633type Round = { sessions: Sessions; lastWords: Record<string, LastWord>; ciChecks: Record<string, CiCheck>; pushChecks: Record<string, PushCheck> }
634
635/** The round last written, so an unchanged one is not written again. */
636let roundWritten = ''
637
638async function roundFile($: EngineInterface): Promise<string | null> {
639  const id = (await read($, me))?.id
640
641  return id ? `${await dataDir($)}/rounds/${id}.json` : null
642}
643
644async function saveRound($: EngineInterface) {
645  const path = await roundFile($)
646  if (!path) return
647  const round: Round = { sessions: await read($, sessions), lastWords: await read($, lastWords), ciChecks: await read($, ciChecks), pushChecks: await read($, pushChecks) }
648  const text = JSON.stringify({ ...round, sessions: { ...round.sessions, fetchedAt: 0 } })
649  if (text === roundWritten) return
650  roundWritten = text
651  try {
652    await $.fs.write(path, JSON.stringify(round))
653  } catch {
654    // The next process waits for its first round instead.
655  }
656}
657
658/** Draws from the last round this session kept while a new process has none of its own yet. */
659export async function loadRound($: EngineInterface) {
660  if ((await read($, sessions)).fetchedAt !== 0) return
661  const path = await roundFile($)
662  const round = path ? await readJson<Round>($, path) : null
663  if (!round?.sessions?.list) return
664  await update($, sessions, s => (s.fetchedAt !== 0 ? s : { ...round.sessions, error: null }))
665  await update($, lastWords, all => ({ ...round.lastWords, ...all }))
666  await update($, ciChecks, all => ({ ...round.ciChecks, ...all }))
667  await update($, pushChecks, all => ({ ...round.pushChecks, ...all }))
668}
669
670export function refreshSoon($: EngineInterface, ms = 0) {
671  // Nowhere to report once this copy of the module is gone (a reload).
672  $.clock.after(ms, () => void refresh($).catch(() => undefined))
673}
674
675// ── Being the PM ──
676
677/** The PM's instructions, in the language the mod speaks now. */
678export const ROLE = (self: Me) => t('role.text', { project: self.projectName, path: self.projectPath, board: BOARD_TOOL, ledger: LEDGER_TOOL })
679
680/**
681 * What a session of a project with a PM is asked to say at its end, so the board need not guess a release from its
682 * words (declaredOf): null for the PM itself, a scratch folder, and a project nobody has claimed, whose sessions
683 * are told nothing.
684 */
685export async function deliveryAsk($: EngineInterface): Promise<string | null> {
686  const self = await read($, me)
687  if (!self || self.isPm || self.isScratch) return null
688  const project = (await read($, projects))[self.projectKey]
689  if (!project?.pmId || project.isDismissed) return null
690
691  return t('role.delivery', { project: self.projectName })
692}
693
694/**
695 * Puts the PM's tools back when the engine has dropped them: a reload of the module (/reload-plugins,
696 * or a change to the mod's folder, reloaded just before one of its commands runs) drops the tools the
697 * copy before it registered, sometimes after the new copy registered them again.
698 */
699export async function ensureTools($: EngineInterface) {
700  const self = await read($, me)
701  if (!self?.isPm) return
702  try {
703    const names = new Set((await $.tool.list()).map(tool => tool.name))
704    if (names.has(BOARD_TOOL) && names.has(LEDGER_TOOL)) return
705    await registerTools($)
706  } catch (err) {
707    $.ui.log(t('log.cannotRegister', { reason: reason(err) }))
708  }
709}
710
711// ── The board pane ──
712
713/** A pane the poll brought back and that closed again at once waits this long, or for the person's next prompt. */
714const REOPEN_MS = 5 * 60_000
715/** When the poll last brought the board back. */
716let reopenedAt = 0
717
718/**
719 * Whether this session's board should be up: what the person last did with it (/board, the pane's close), kept in
720 * the session's file so it outlives a reload of the module and a restart of the session's process; with nothing said
721 * yet, up for the PM.
722 */
723export function isBoardWanted(self: Me | null): boolean {
724  return getMine()?.isBoardOpen ?? !!self?.isPm
725}
726
727/**
728 * Whether a close of a pane keeps the board closed: the person's (the pane's close mark or key, the app closing it)
729 * and /board's own. Not the engine's unload, which drops a pane nothing draws any more.
730 */
731export function closesBoard(e: { id: string; origin: { kind: string } }): boolean {
732  return e.id === PANE && e.origin.kind !== 'unload'
733}
734
735/** Keeps what the person did with the board. */
736export async function rememberBoard($: EngineInterface, isOpen: boolean) {
737  await changeMine($, t => (t.isBoardOpen === isOpen ? t : { ...t, isBoardOpen: isOpen }))
738}
739
740export async function openBoard($: EngineInterface, self: Me | null) {
741  const title = self?.isPm ? t('pane.titlePm', { project: self.projectName }) : self ? t('pane.titleBoard', { project: self.projectName }) : t('pane.titleBoardNone')
742
743  return $.ui.open({ id: PANE, title })
744}
745
746/**
747 * Brings the board back when it should be up and is not. The panes are the session's process's: when the desktop app
748 * stops an idle session (under memory pressure) and resumes it later, the new process starts with none; and a pane
749 * the engine drops (an unload) goes without a word to the hooks. The person's own close is kept and stays closed.
750 *
751 * `start` is a new process or copy of the module; `prompt` the person's input, which also places a pane left
752 * undrawn (an open nobody asked for waits on a narrow window); `poll` the timer, which tries once in REOPEN_MS so a
753 * pane that cannot be drawn is not opened over and over.
754 */
755export async function ensureBoard($: EngineInterface, why: 'start' | 'prompt' | 'poll') {
756  const self = await identify($)
757  if (!isBoardWanted(self)) return
758  const pane = (await $.ui.panes()).find(one => one.id === PANE)
759  if (pane && (pane.isPlaced || why !== 'prompt')) return
760  if (why === 'poll') {
761    const now = await $.clock.now()
762    if (now - reopenedAt < REOPEN_MS) return
763    reopenedAt = now
764  }
765  await openBoard($, self)
766}
767
768export async function registerTools($: EngineInterface) {
769  await $.tool.register({
770    name: 'board',
771    description: t('tool.board.description'),
772    inputSchema: {
773      type: 'object',
774      properties: {
775        scope: { type: 'string', enum: ['project', 'all'], description: t('tool.board.scope') },
776        include_done: { type: 'boolean', description: t('tool.board.includeDone') },
777      },
778    },
779    isDeferred: false,
780  })
781  await $.tool.register({
782    name: 'ledger',
783    description: t('tool.ledger.description'),
784    inputSchema: {
785      type: 'object',
786      properties: {
787        action: { type: 'string', enum: ['add', 'update', 'list'] },
788        id: { type: 'number', description: t('tool.ledger.id') },
789        text: { type: 'string', description: t('tool.ledger.text') },
790        owner: { type: 'string', description: t('tool.ledger.owner') },
791        status: { type: 'string', enum: ['open', 'done', 'dropped'] },
792        note: { type: 'string', description: t('tool.ledger.note') },
793        force: { type: 'boolean', description: t('tool.ledger.force') },
794      },
795      required: ['action'],
796    },
797    isDeferred: false,
798  })
799}
800
801export async function claim($: EngineInterface): Promise<string> {
802  const self = await identify($)
803  if (!self?.id) return t('claim.noId')
804  if (self.isScratch) return t('claim.scratch')
805  const project = await loadProject($, self.projectKey, self.projectName, self.projectPath)
806  const before = project.pmId
807  await saveProject($, { ...project, pmId: self.id, isDismissed: false })
808  await update($, me, m => (m ? { ...m, isPm: true } : m))
809  await registerTools($)
810  statusesBefore = null
811  await rememberBoard($, true)
812  await openBoard($, { ...self, isPm: true })
813  refreshSoon($)
814
815  return before && before !== self.id ? t('claim.tookOver', { project: self.projectName, before }) : t('claim.done', { project: self.projectName })
816}
817
818export async function release($: EngineInterface): Promise<string> {
819  const self = await read($, me)
820  if (!self?.isPm) return t('release.notPm')
821  const project = await loadProject($, self.projectKey, self.projectName, self.projectPath)
822  if (project.pmId === self.id) await saveProject($, { ...project, pmId: null })
823  await update($, me, m => (m ? { ...m, isPm: false } : m))
824
825  return t('release.done', { project: self.projectName })
826}
827
828export async function startPmSession($: EngineInterface) {
829  const self = await read($, me)
830  if (!self) return
831  await update($, busy, () => 'start-pm')
832  try {
833    const args: Record<string, unknown> = {
834      initiation: 'user_asked',
835      context: 'fresh',
836      title: `${self.projectName} · PM`,
837      name: 'pm',
838      prompt: t('start.prompt', { claim: CLAIM, project: self.projectName, board: BOARD_TOOL }),
839      background: t('start.background', { project: self.projectName, path: self.projectPath }),
840      use_worktree: false,
841    }
842    // From a worktree, the PM still sits at the repository itself.
843    if (mine && mine.cwd.replace(/[\\/]+$/, '') !== self.projectPath) args.cwd = self.projectPath
844    const result = await $.mcp.call(STARTER, 'start_session', args)
845    const text = textOf(result)
846    if (result.isError) throw new Error(text || t('error.cannotStart'))
847    await update($, notice, () => ({ tone: 'good', text: t('notice.pmCreated', { project: self.projectName }) }))
848    await update($, isBandHidden, () => true)
849  } catch (err) {
850    await update($, notice, () => ({ tone: 'bad', text: t('notice.pmFailed', { reason: reason(err) }) }))
851  } finally {
852    await update($, busy, () => null)
853  }
854}
855
856export async function dismissBand($: EngineInterface) {
857  const self = await read($, me)
858  await update($, isBandHidden, () => true)
859  if (!self) return
860  const project = await loadProject($, self.projectKey, self.projectName, self.projectPath)
861  await saveProject($, { ...project, isDismissed: true })
862}
863
864export async function archive($: EngineInterface, one: Session) {
865  await update($, busy, () => one.id)
866  await update($, confirm, () => null)
867  try {
868    const result = await $.mcp.call(MGMT, 'archive_session', { session_id: one.id, reason: t('archive.reason', { title: one.title }) })
869    const text = textOf(result)
870    if (result.isError || /not archived|refus|declin|denied|still working|cancel/i.test(text)) {
871      await update($, notice, () => ({ tone: 'bad', text: t('notice.notArchived', { title: one.title, why: text || t('archive.refused') }) }))
872    } else {
873      await update($, sessions, s => ({ ...s, list: s.list.filter(e => e.id !== one.id) }))
874      await update($, notice, () => ({ tone: 'good', text: t('notice.archived', { title: one.title }) }))
875    }
876  } catch (err) {
877    await update($, notice, () => ({ tone: 'bad', text: t('notice.notArchived', { title: one.title, why: reason(err) }) }))
878  } finally {
879    await update($, busy, () => null)
880  }
881  await refresh($, true)
882}
883
884// ── The PM's tools ──
885
886export function str(v: unknown): string | undefined {
887  return typeof v === 'string' ? v : undefined
888}
889
890export async function board($: EngineInterface, input: Record<string, unknown>): Promise<string> {
891  const self = await identify($)
892  await refresh($, true, input.scope === 'all')
893  const state = await read($, sessions)
894  if (state.error && state.list.length === 0) return t('board.cannotRead', { error: state.error })
895  const all = await read($, tracks)
896  const words = await read($, lastWords)
897  const ci = await read($, ciChecks)
898  const push = await read($, pushChecks)
899  const files = await read($, projects)
900  const now = await $.clock.now()
901  const isIncludingDone = input.include_done === true
902  const groups = groupProjects(state.list)
903  const chosen: Project[] =
904    input.scope === 'all' || !self
905      ? groups
906      : [groups.find(p => p.key === self.projectKey) ?? { key: self.projectKey, name: self.projectName, path: self.projectPath, sessions: [] }]
907
908  return chosen
909    .map(p =>
910      boardText({
911        project: { name: p.name, path: p.path },
912        sessions: p.sessions,
913        everyone: state.list,
914        tracks: all,
915        words,
916        ci,
917        push,
918        ledger: files[p.key]?.ledger ?? [],
919        now,
920        isIncludingDone,
921      }),
922    )
923    .join('\n\n---\n\n')
924}
925
926/** Why the owner's work cannot be counted done yet, or null when nothing stands in the way. */
927async function ownerBlocker($: EngineInterface, ownerId: string): Promise<string | null> {
928  await refresh($, true, true)
929  const list = (await read($, sessions)).list
930  const one = list.find(s => s.id === ownerId)
931  const words = await read($, lastWords)
932  const kept = (await read($, tracks))[ownerId] ?? (await readJson<Track>($, `${await dataDir($)}/sessions/${ownerId}.json`))
933  const track = hasReport(kept) ? (one ? liveTracks([one], { [ownerId]: kept }, words)[ownerId] : kept) : undefined
934  const title = one?.title ?? track?.title ?? ownerId
935  if (!one) {
936    // Gone from the list (archived): its last words still count, and GitHub and git still answer for them.
937    if (!track) return null
938    const said = latestWords(track)
939    if (isCiPending(said)) {
940      const check = await checkCi($, ownerId, track.cwd, said, track.turnAt)
941      if (check.state !== 'success') {
942        const why = check.state === 'failure' ? t('blocker.ciFailedOn', { target: check.target }) : t('blocker.ciRunning')
943
944        return t('blocker.quoted', { title, why, said: clip(said, 120) })
945      }
946    }
947    const left = leftOf(said, await checkPush($, ownerId, track.cwd, said))
948    if (left.length === 0) return null
949
950    return t('blocker.quoted', { title, why: t('blocker.undelivered', { kinds: deliveryText(left) }), said: clip(said, 120) })
951  }
952  await checkPush($, ownerId, one.cwd, ciWords(track, words[ownerId]))
953  const status = statusOf(one, track, words[ownerId], await $.clock.now(), (await read($, ciChecks))[ownerId], (await read($, pushChecks))[ownerId])
954  const said = ciWords(track, words[ownerId])
955  const blocking: Partial<Record<Status, true>> = { ci: true, failed: true, unreleased: true, running: true, waiting: true, asking: true, open: true }
956  if (!blocking[status]) return null
957  const why = t(`blocker.why.${status as 'ci' | 'failed' | 'unreleased' | 'running' | 'waiting' | 'asking' | 'open'}`)
958
959  return said ? t('blocker.lastReport', { title, why, said: clip(said, 120) }) : t('blocker.plain', { title, why })
960}
961
962export function ledgerText(items: LedgerItem[]): string {
963  if (items.length === 0) return t('ledger.empty')
964
965  return items
966    .map(i =>
967      t('ledger.row', {
968        code: itemCode(i.id),
969        id: i.id,
970        status: i.status,
971        text: i.text,
972        owner: i.owner ?? t('ledger.unassigned'),
973        note: i.note ? t('board.itemNote', { note: i.note }) : '',
974      }),
975    )
976    .join('\n')
977}
978
979export async function ledger($: EngineInterface, input: Record<string, unknown>): Promise<string> {
980  const self = await identify($)
981  if (!self || self.isScratch) return t('ledger.noProject')
982  const project = await loadProject($, self.projectKey, self.projectName, self.projectPath)
983  const now = await $.clock.now()
984  const action = str(input.action)
985  if (action === 'list') return ledgerText(project.ledger)
986  if (action === 'add') {
987    const text = str(input.text)?.trim()
988    if (!text) return t('ledger.needText')
989    const item: LedgerItem = {
990      id: project.nextId,
991      text,
992      owner: str(input.owner) || null,
993      status: 'open',
994      note: str(input.note) ?? '',
995      createdAt: now,
996      updatedAt: now,
997    }
998    await saveProject($, { ...project, ledger: [...project.ledger, item], nextId: project.nextId + 1 })
999
1000    return `${t('ledger.added', { code: itemCode(item.id), id: item.id })}\n\n${ledgerText([...project.ledger, item])}`
1001  }
1002  if (action === 'update') {
1003    const id = typeof input.id === 'number' ? input.id : Number(input.id)
1004    const item = project.ledger.find(i => i.id === id)
1005    if (!item) return `${t('ledger.notFound', { id: String(input.id) })}\n\n${ledgerText(project.ledger)}`
1006    const status = str(input.status)
1007    if (status === 'done' && item.status !== 'done') {
1008      const owner = input.owner === undefined ? item.owner : str(input.owner) || null
1009      const blocker = owner ? await ownerBlocker($, owner) : null
1010      if (blocker && !(input.force === true && str(input.note)?.trim())) {
1011        return `${t('ledger.refused', { code: itemCode(id), blocker })}\n\n${ledgerText(project.ledger)}`
1012      }
1013    }
1014    const changed: LedgerItem = {
1015      ...item,
1016      text: str(input.text)?.trim() || item.text,
1017      owner: input.owner === undefined ? item.owner : str(input.owner) || null,
1018      status: status === 'open' || status === 'done' || status === 'dropped' ? status : item.status,
1019      note: str(input.note) ?? item.note,
1020      updatedAt: now,
1021    }
1022    const list = project.ledger.map(i => (i.id === id ? changed : i))
1023    await saveProject($, { ...project, ledger: list })
1024
1025    return `${t('ledger.updated', { code: itemCode(id) })}\n\n${ledgerText(list)}`
1026  }
1027
1028  return t('ledger.badAction')
1029}
1030
1031
hooks/messages.ts 553 lines
1// Every word the mod shows people or hands the PM model, in each language it speaks.
2// `{name}` takes a value; `{n|one|other}` picks by the number `n` (English plurals).
3
4export type Lang = 'en' | 'zh-TW'
5
6export const LANGS: readonly Lang[] = ['en', 'zh-TW']
7
8const zhTW = {
9  // Statuses: `label` on the pane, `tag` on the PM's board.
10  'status.asking.label': '待回覆',
11  'status.asking.tag': '待回覆',
12  'status.failed.label': 'CI 失敗',
13  'status.failed.tag': 'CI 失敗・需處理',
14  'status.open.label': '未完成',
15  'status.open.tag': '中斷・未完成',
16  'status.unreleased.label': '待發布',
17  'status.unreleased.tag': '待發布・未完成',
18  'status.ci.label': '等待 CI',
19  'status.ci.tag': '等待 CI・未完成',
20  'status.running.label': '執行中',
21  'status.running.tag': '執行中',
22  'status.waiting.label': '背景執行',
23  'status.waiting.tag': '背景執行',
24  'status.done.label': '已結束',
25  'status.done.tag': '已結束',
26  'status.stale.label': '長期閒置',
27  'status.stale.tag': '長期閒置',
28
29  // Time and names.
30  'time.unknown': '不明',
31  'time.now': '剛剛',
32  'time.minutes': '{n} 分鐘前',
33  'time.hours': '{n} 小時前',
34  'time.days': '{n} 天前',
35  'project.scratch': '臨時資料夾',
36  'project.unsorted': '未分類',
37  'session.untitled': '(未命名)',
38  'delivery.commit': 'commit',
39  'delivery.push': 'push',
40  'delivery.release': '發布/部署/上線',
41  'list.joiner': '、',
42  'list.steps': ';',
43
44  // Errors and logs.
45  'error.notArray': 'list_sessions 回傳的不是陣列',
46  'error.listFailed': '讀取 Session 清單失敗',
47  'error.noTranscript': '讀不到紀錄',
48  'error.cannotStart': '開不了新 Session',
49  'log.cannotWrite': 'project-pm: 寫不進 Session 紀錄:{reason}',
50  'log.cannotRegister': 'project-pm: 無法重新註冊 PM 工具:{reason}',
51
52  // Toasts.
53  'toast.asking': '「{title}」待你回覆',
54  'toast.failed': '「{title}」的 CI 失敗了',
55  'toast.unreleased': '「{title}」已停下,但回報尚未交付',
56  'toast.open': '「{title}」已停止,仍有 {n} 項步驟未完成',
57
58  // The PM's instructions.
59  'role.text': `# 你是「{project}」專案的 PM
60
61這個 Session 是專案「{project}」({path})的 PM。使用者只跟你說話,靠你確保這個專案沒有任何工作遺漏。你的工作是掌握狀況、派工、追蹤、驗收,不是自己寫程式。
62
63規則:
641. 使用者問進度、問有沒有漏掉、或你要回報之前,先呼叫 {board} 看最新看板,不要憑記憶回答。
652. 使用者交代新工作:先用 {ledger}(action: "add")登記為工作項目,再決定交給現有 Session(SendMessage,to 填它的 local_… id)或開新 Session(start_session),派出後用 ledger update 填上 owner。
663. 不要自己動手改專案的程式碼;實作交給子 Session。使用者明確要你自己做時才例外。
674. CI 還在跑、還沒部署完成的工作一律不算完成:負責 Session 狀態為「等待 CI」「CI 失敗」「待發布」「執行中」「背景執行」「待回覆」或「未完成」時,ledger 會拒絕結案。
685. 一個工作項目只有在你確認對應 Session 真的做完(看板上沒有未完成步驟、收尾摘要沒提到還沒做的事,必要時用 list_events 看它的紀錄)之後,才用 ledger update 標成 done。
696. 說「都完成了」之前,看板的「需要處理」必須是空的、工作項目沒有 open;不是的話,逐條列出還差什麼。
707. 子 Session 在等使用者回覆時,把它的問題原文轉述給使用者,使用者回答後用 SendMessage 轉達回去。
718. 封存 Session 前先確認它沒有未完成步驟、沒有開著的 PR,並問過使用者。
729. 提到 Session 時用 [標題](#local_…) 連結它。`,
73  'role.delivery': `「{project}」有 PM Session 在追蹤各 Session 的交付狀態。完成一項改動程式或資料的工作、收尾時,請在 wrap_up 摘要(沒有 wrap_up 時就是最後的回答)最後加一行交付宣告,三選一:
74交付:不需要部署
75交付:已部署(版本或位置)
76交付:待部署(等什麼)
77只有改動需要發布、但還沒發布時才寫「待部署」。commit 和 push 由 git 判斷,不用寫在這行;這行是給 PM 看板用的,不需要向使用者解釋。`,
78  'role.justClaimed': '使用者剛用 /pm 把這個 Session 設成 PM。先呼叫 {board},用幾句話報告專案現況:哪些 Session 在等使用者回覆、哪些停下但還有步驟、工作項目有沒有沒完成的。',
79
80  // The PM's tools.
81  'tool.board.description':
82    '專案狀態報告:列出此專案所有子 Session 的狀態(執行中、待回覆、CI 失敗、等待 CI、待發布、中斷・未完成、背景執行、已結束;CI 狀態會用 gh 向 GitHub 確認)、任務清單進度、最新指令與收尾摘要,以及工作項目和需要處理的事項。回報進度或判斷是否有遺漏前必須先呼叫。',
83  'tool.board.scope': 'project(預設)只看這個專案;all 看所有專案。',
84  'tool.board.includeDone': '一併列出已結束且無待辦的 Session 與已完成的工作項目。預設 false。',
85  'tool.ledger.description':
86    '專案 PM 的工作項目清單:使用者交代的每件工作都要登記在這裡,直到確認完成。顯示編號為 T-01 形式,操作時 id 填數字。add 新增(text 必填,owner 可填負責的 local_… Session id);update 改 owner、status(open / done / dropped)或 note;list 列出全部。',
87  'tool.ledger.id': 'update 時要修改的工作項目 id(T-03 填 3)',
88  'tool.ledger.text': '工作項目內容(add 必填;update 時可改寫)',
89  'tool.ledger.owner': '負責的 Session id(local_…);空字串表示取消指派',
90  'tool.ledger.note': '備註:進度、驗收結果、為什麼放棄',
91  'tool.ledger.force': '負責 Session 仍在等 CI 或未完成時,強制結案。只有使用者明確確認時才能用,且 note 必填。',
92
93  // Becoming the PM, stepping down, starting a PM session, archiving.
94  'claim.noId': '無法取得此 Session 的 id(可能不在桌面版中),無法擔任 PM。',
95  'claim.scratch': '此 Session 位於臨時資料夾,不屬於任何專案,無法擔任 PM。',
96  'claim.tookOver': '此 Session 已接任「{project}」的 PM(原 PM:{before})。',
97  'claim.done': '此 Session 已擔任「{project}」的 PM。',
98  'release.notPm': '此 Session 目前不是 PM。',
99  'release.done': '此 Session 已卸任「{project}」的 PM;工作項目保留於專案中,由下一任 PM 接手。',
100  'start.prompt': '{claim} 你是「{project}」專案的 PM。先呼叫 {board} 看所有 Session 的狀態,再用幾句話向使用者報告:哪些在等他回覆、哪些停下但任務清單還有步驟、工作項目有沒有沒完成的。',
101  'start.background':
102    '使用者希望每個專案都有一個 PM Session:使用者只跟 PM 說話,PM 追蹤專案「{project}」({path})裡所有子 Session 的工作狀態與工作項目,確保沒有工作遺漏。project-pm Mod 會在這個 Session 讀到開頭的標記時把它設成 PM,並提供看板與工作項目工具。',
103  'notice.pmCreated': '已建立 PM Session「{project} · PM」,可在側欄開啟。',
104  'notice.pmFailed': '無法自動建立 PM Session({reason})。請手動開啟新 Session 並執行 /pm。',
105  'archive.reason': '專案 PM:封存「{title}」',
106  'archive.refused': '封存要求遭拒',
107  'notice.notArchived': '「{title}」未封存:{why}',
108  'notice.archived': '已封存「{title}」,可於側欄的封存區復原。',
109
110  // Commands.
111  'command.pm': '指定此 Session 為專案 PM(/pm off 卸任,/pm lang en|zh-TW|auto 切換語言)',
112  'command.board': '開啟或收合專案看板',
113  'command.boardClosed': '已收合專案看板,再次執行 /board 可重新開啟。',
114  'command.boardOpened': '已開啟專案看板。',
115  'command.denyBoard': 'project-pm:專案看板讀取失敗,請稍後再試,或改用 list_sessions / list_events。',
116  'command.denyLedger': 'project-pm:工作項目寫入失敗,請再試一次。',
117  'pane.titlePm': '{project} · PM',
118  'pane.titleBoard': '{project} · 專案看板',
119  'pane.titleBoardNone': '專案看板',
120  'lang.set': '看板語言已設為 {lang}。',
121  'lang.setAuto': '看板語言改為跟隨系統(目前:{lang})。',
122  'lang.notSaved': '看板語言已在這個 Session 改為 {lang},但無法存進設定({reason});下次啟動會恢復原設定。',
123  'lang.usage': '用法:/pm lang en、/pm lang zh-TW 或 /pm lang auto(跟隨系統)。目前:{lang}。',
124
125  // The board as the PM model reads it.
126  'board.cannotRead': '讀不到 Session 清單:{error}',
127  'board.title': '# 專案狀態報告:{project}',
128  'board.path': '路徑:{path}',
129  'board.counts':
130    'Sessions {total}(不含 PM)|待回覆 {asking}|CI 失敗 {failed}|等待 CI {ci}|待發布 {unreleased}|中斷・未完成 {open}|執行中 {running}|背景執行 {waiting}|已結束 {done}|長期閒置 {stale}',
131  'board.progress': '工作項目完成 {done}/{total}(專案進度以工作項目為準;Session 收尾時會把自己的任務清單全部勾選,不代表工作已完成)',
132  'board.name': '「{title}」({id})',
133  'board.attention.asking': '- [待回覆] {name} 等待使用者決策:{said}',
134  'board.attention.open': '- [中斷・未完成] {name} 已停止,任務清單仍有 {n} 項未完成:{steps}',
135  'board.attention.ci': '- [等待 CI・未完成] {name} 最後回報 CI 尚未跑完,不能視為完成:{said}',
136  'board.attention.unreleased': '- [待發布・未完成] {name} 最後回報尚未交付({kinds}),不能視為完成:{said}',
137  'board.attention.failed': '- [CI 失敗・需處理] {name} GitHub 上的 CI({target})失敗,要修正後重跑:{said}',
138  'board.attention.failedTarget': '最近一次',
139  'board.attention.caveat': '- [需追蹤] {name} 收尾摘要載明尚有未完成事項:{said}',
140  'board.attention.pr': '- [PR 開啟中] {name}',
141  'board.attention.unassigned': '- [未指派] 工作項目 {code}「{text}」尚未指派負責 Session',
142  'board.attention.lost': '- [負責人失聯] 工作項目 {code}「{text}」的負責 Session {owner} 已不在清單中(可能已封存),需確認是否完成',
143  'board.owner.unassigned': '未指派',
144  'board.owner.gone': '{id}(已不在清單中)',
145  'board.owner.status': '{title}({tag}{elsewhere})',
146  'board.owner.elsewhere': '・其他專案的 Session',
147  'board.section.attention': '## 需要處理',
148  'board.section.items': '## 工作項目',
149  'board.none': '(無)',
150  'board.allDone': '(全部完成)',
151  'board.item': '- {code} (id {id}) [{status}] {text}|負責:{owner}{note}',
152  'board.itemNote': '|備註:{note}',
153  'board.flag.pr': 'PR 開啟中',
154  'board.flag.pinned': '已釘選',
155  'board.lastActive': '最後活動 {ago}{flags}',
156  'board.flags': '|{flags}',
157  'board.tasks': '任務 {done}/{total}:{steps}',
158  'board.prompt': '最新指令:{text}',
159  'board.recap': '收尾摘要:{text}',
160  'board.answer': '最後回報:{text}',
161  'board.ask': '待決策問題:{text}',
162  'board.background': '背景工作 {n} 項執行中:{tasks}',
163  'board.backgroundTask': '{label}({ago}開始)',
164  'board.untracked': '(無追蹤紀錄:此 Session 在 Mod 安裝前開啟)最後訊息:{said}',
165  'board.readFailed': '讀取失敗:{error}',
166  'board.reading': '讀取中',
167  'board.hidden': '另有 {n} 個已結束且無待辦的 Session 未列出;以 include_done: true 檢視全部。',
168
169  // Why the ledger will not close an item yet.
170  'blocker.ciFailedOn': 'GitHub 上的 CI({target})失敗',
171  'blocker.ciRunning': '最後回報 CI 尚未跑完',
172  'blocker.undelivered': '最後回報尚未交付({kinds})',
173  'blocker.quoted': '負責的 Session「{title}」{why}({said})。',
174  'blocker.lastReport': '負責的 Session「{title}」{why}(最後回報:{said})。',
175  'blocker.plain': '負責的 Session「{title}」{why}。',
176  'blocker.why.ci': 'CI 尚未跑完',
177  'blocker.why.failed': 'GitHub 上的 CI 失敗',
178  'blocker.why.unreleased': '最後回報尚未交付(未 commit、push 或發布)',
179  'blocker.why.running': '仍在執行中',
180  'blocker.why.waiting': '仍有背景作業在跑',
181  'blocker.why.asking': '正在等使用者回覆',
182  'blocker.why.open': '任務清單仍有未完成步驟',
183
184  // The ledger's answers.
185  'ledger.empty': '目前沒有工作項目。',
186  'ledger.row': '{code} (id {id}) [{status}] {text}|負責:{owner}{note}',
187  'ledger.unassigned': '未指派',
188  'ledger.noProject': '此 Session 不屬於任何專案,沒有工作項目。',
189  'ledger.needText': 'add 需要 text。',
190  'ledger.added': '已新增工作項目 {code}(id {id})。',
191  'ledger.notFound': '找不到工作項目 id {id}。',
192  'ledger.refused': '無法將 {code} 標記為完成:{blocker}\n確認 CI 通過、部署完成後再結案;若使用者已明確確認可結案,帶 force: true 並在 note 寫明確認內容。',
193  'ledger.updated': '已更新工作項目 {code}。',
194  'ledger.badAction': 'action 要是 add、update 或 list。',
195
196  // The band above the prompt.
197  'band.title': '專案 PM',
198  'band.noPm': ' 「{project}」尚未指派 PM',
199  'band.starting': '建立中…',
200  'band.start': '建立 PM Session',
201  'band.claim': '由此 Session 擔任',
202  'band.skip': '略過',
203
204  // The pane.
205  'pane.syncing': '正在同步 Session…',
206  'pane.cannotRead': '無法讀取 Session 清單:{error}',
207  'pane.projects': '{n} 個專案',
208  'pane.pmSelf': 'PM:本 Session',
209  'pane.pmOther': 'PM:已指派',
210  'pane.pmNone': 'PM:未指派',
211  'pane.ratio': '完成率 {percent}%',
212  'pane.noData': '尚無資料',
213  'pane.itemsDone': '工作項目完成 {done}/{total}',
214  'pane.stepsDone': 'Session 步驟完成 {done}/{total}(尚無工作項目)',
215  'pane.noItems': '尚無工作項目',
216  'pane.itemsOpen': '{n} 項進行中',
217  'pane.itemsUnassigned': ' · {n} 項未指派',
218  'pane.kpiRunning': '執行中',
219  'pane.kpiAsking': '待回覆',
220  'pane.kpiOpen': '尚未完成',
221  'pane.portfolio': 'PORTFOLIO',
222  'pane.project': 'PROJECT',
223  'pane.allProjects': '全部專案',
224  'pane.sessionsCount': '{n} Sessions · {role}',
225  'pane.scopeProject': '本專案',
226  'pane.scopeAll': '全部',
227  'pane.confirmArchiveWarn': '確定封存?{warn}',
228  'pane.confirmArchive': '確定封存此 Session?',
229  'pane.archive': '封存',
230  'pane.archiving': '封存中…',
231  'pane.cancel': '取消',
232  'pane.question': '問題:{text}',
233  'pane.instruction': '指令:{text}',
234  'pane.noReport': '尚無回報',
235  'pane.cannotReadTranscript': '無法讀取紀錄:{error}',
236  'pane.reading': '讀取中…',
237  'pane.steps': '步驟 {done}/{total}',
238  'pane.prOpen': 'PR 開啟中',
239  'pane.untracked': '無追蹤紀錄',
240  'pane.warnSteps': '尚有 {n} 項步驟未完成',
241  'pane.warnPr': 'PR 仍開啟',
242  'pane.warnPinned': '已釘選',
243  'pane.moreSteps': ' (另 {n} 項)',
244  'pane.caveat': '需追蹤',
245  'pane.elsewhere': '・其他專案',
246  'pane.ownerNone': '尚未指派',
247  'pane.owner': '負責 {title}・{status}{elsewhere}',
248  'pane.ownerGone': '負責 Session 已不在清單中',
249  'pane.projectCounts': '{live} 進行中 · {ended} 已結束{pm}',
250  'pane.projectPm': ' · PM',
251  'pane.noLive': '沒有進行中的 Session。',
252  'pane.noSessions': '此專案目前沒有其他 Session。',
253  'pane.noPm': '此專案尚未指派 PM',
254  'pane.items': '工作項目',
255  'pane.sessions': 'SESSIONS',
256  'pane.sessionsMeta': '{live} 進行中 · {ended} 已結束',
257  'pane.hideEnded': '收合已結束的 Session',
258  'pane.showEnded': '顯示已結束的 Session({n})',
259  'pane.synced': '同步於 {ago} · 每 30 秒自動更新',
260  'pane.refresh': '重新同步',
261} as const
262
263export type MessageKey = keyof typeof zhTW
264
265const en: Record<MessageKey, string> = {
266  'status.asking.label': 'Waiting on you',
267  'status.asking.tag': 'Waiting on you',
268  'status.failed.label': 'CI failed',
269  'status.failed.tag': 'CI failed · needs fixing',
270  'status.open.label': 'Unfinished',
271  'status.open.tag': 'Stopped · unfinished',
272  'status.unreleased.label': 'Not released',
273  'status.unreleased.tag': 'Not released · unfinished',
274  'status.ci.label': 'Waiting on CI',
275  'status.ci.tag': 'Waiting on CI · unfinished',
276  'status.running.label': 'Running',
277  'status.running.tag': 'Running',
278  'status.waiting.label': 'In the background',
279  'status.waiting.tag': 'In the background',
280  'status.done.label': 'Ended',
281  'status.done.tag': 'Ended',
282  'status.stale.label': 'Idle',
283  'status.stale.tag': 'Idle',
284
285  'time.unknown': 'unknown',
286  'time.now': 'just now',
287  'time.minutes': '{n} min ago',
288  'time.hours': '{n} {n|hour|hours} ago',
289  'time.days': '{n} {n|day|days} ago',
290  'project.scratch': 'Scratch folder',
291  'project.unsorted': 'Unsorted',
292  'session.untitled': '(untitled)',
293  'delivery.commit': 'commit',
294  'delivery.push': 'push',
295  'delivery.release': 'release, deploy or go live',
296  'list.joiner': ', ',
297  'list.steps': '; ',
298
299  'error.notArray': 'list_sessions did not return a list',
300  'error.listFailed': 'Could not read the session list',
301  'error.noTranscript': 'Could not read the transcript',
302  'error.cannotStart': 'Could not start a new session',
303  'log.cannotWrite': 'project-pm: could not write the session record: {reason}',
304  'log.cannotRegister': 'project-pm: could not register the PM tools again: {reason}',
305
306  'toast.asking': '"{title}" is waiting on you',
307  'toast.failed': 'CI failed for "{title}"',
308  'toast.unreleased': '"{title}" stopped, but its work is not delivered yet',
309  'toast.open': '"{title}" stopped with {n} {n|step|steps} left',
310
311  'role.text': `# You are the PM of the "{project}" project
312
313This session is the PM of the project "{project}" ({path}). The user talks only to you and counts on you to make sure no work in this project slips through. Your job is to keep track, hand out work, follow up and check it, not to write the code yourself.
314
315Rules:
3161. When the user asks about progress or whether anything was missed, and before you report, call {board} for the latest board. Do not answer from memory.
3172. When the user asks for new work: record it first as a work item with {ledger} (action: "add"), then hand it to an existing session (SendMessage, with to set to its local_… id) or start a new one (start_session), and set its owner with a ledger update once it is handed out.
3183. Do not change the project's code yourself; sub-sessions do the work. The only exception is when the user explicitly asks you to.
3194. Work whose CI is still running, or that is not deployed yet, is never done: while the owning session is "Waiting on CI", "CI failed", "Not released", "Running", "In the background", "Waiting on you" or "Unfinished", the ledger refuses to close the item.
3205. Mark a work item done with a ledger update only after you have confirmed that its session really finished (no steps left on the board, nothing left undone in its recap; read its transcript with list_events when in doubt).
3216. Before you say "it's all done", the board's "Needs attention" must be empty and no work item may be open; otherwise list, one by one, what is still missing.
3227. When a sub-session is waiting on the user, pass its question on word for word, then relay the user's answer back with SendMessage.
3238. Before archiving a session, make sure it has no steps left and no open PR, and ask the user.
3249. When you mention a session, link it as [title](#local_…).`,
325  'role.delivery': `"{project}" has a PM session tracking each session's delivery. When you finish a piece of work that changed code or data, end your wrap_up recap (or, without wrap_up, your final answer) with one delivery line, one of:
326Delivery: not needed
327Delivery: deployed (version or where)
328Delivery: pending (what it waits on)
329Write pending only when the change needs releasing and has not gone out yet. Commit and push are read from git, not from this line. The line is for the PM's board: no need to explain it to the user.`,
330  'role.justClaimed': 'The user just made this session the PM with /pm. Call {board} first, then report the state of the project in a few sentences: which sessions are waiting on the user, which stopped with steps left, and whether any work item is still open.',
331
332  'tool.board.description':
333    "Project status report: every sub-session of this project with its status (Running, Waiting on you, CI failed, Waiting on CI, Not released, Stopped · unfinished, In the background, Ended; CI is checked on GitHub with gh), its task list progress, latest instruction and recap, plus the work items and what needs attention. Call it before reporting progress or judging whether anything was missed.",
334  'tool.board.scope': 'project (default) shows this project only; all shows every project.',
335  'tool.board.includeDone': 'Also list ended sessions with nothing left and work items that are done. Default false.',
336  'tool.ledger.description':
337    "The project PM's work items: record every piece of work the user asks for here until it is confirmed done. Items read as T-01; pass the number as id. add creates one (text required, owner optional: the owning local_… session id); update changes owner, status (open / done / dropped) or note; list shows them all.",
338  'tool.ledger.id': 'The id of the work item to update (3 for T-03)',
339  'tool.ledger.text': 'What the work item is (required for add; may be rewritten on update)',
340  'tool.ledger.owner': 'The owning session id (local_…); an empty string unassigns it',
341  'tool.ledger.note': 'A note: progress, how it was checked, why it was dropped',
342  'tool.ledger.force': 'Close the item although its owner is still waiting on CI or unfinished. Only when the user has explicitly confirmed it; note is then required.',
343
344  'claim.noId': "This session's id is not available (perhaps it is not in the desktop app), so it cannot be the PM.",
345  'claim.scratch': 'This session is in a scratch folder and belongs to no project, so it cannot be the PM.',
346  'claim.tookOver': 'This session is now the PM of "{project}" (previous PM: {before}).',
347  'claim.done': 'This session is now the PM of "{project}".',
348  'release.notPm': 'This session is not the PM.',
349  'release.done': 'This session is no longer the PM of "{project}"; the work items stay with the project for the next PM.',
350  'start.prompt': '{claim} You are the PM of the "{project}" project. Call {board} first to see every session, then report to the user in a few sentences: which sessions are waiting on them, which stopped with steps left on their task list, and whether any work item is still open.',
351  'start.background':
352    'The user wants one PM session per project: they talk only to the PM, and the PM keeps track of the status and work items of every sub-session in the project "{project}" ({path}), so that no work slips through. The project-pm mod makes this session the PM when it reads the marker at the start, and gives it the board and work item tools.',
353  'notice.pmCreated': 'Started the PM session "{project} · PM"; open it from the sidebar.',
354  'notice.pmFailed': 'Could not start a PM session ({reason}). Open a new session yourself and run /pm.',
355  'archive.reason': 'Project PM: archiving "{title}"',
356  'archive.refused': 'the archive request was refused',
357  'notice.notArchived': '"{title}" was not archived: {why}',
358  'notice.archived': 'Archived "{title}"; restore it from the archive in the sidebar.',
359
360  'command.pm': 'Make this session the project PM (/pm off to step down, /pm lang en|zh-TW|auto for the language)',
361  'command.board': 'Open or close the project board',
362  'command.boardClosed': 'Closed the project board; run /board again to open it.',
363  'command.boardOpened': 'Opened the project board.',
364  'command.denyBoard': 'project-pm: the board could not be read. Try again shortly, or use list_sessions / list_events.',
365  'command.denyLedger': 'project-pm: the work item could not be saved. Please try again.',
366  'pane.titlePm': '{project} · PM',
367  'pane.titleBoard': '{project} · Board',
368  'pane.titleBoardNone': 'Project board',
369  'lang.set': 'The board now speaks {lang}.',
370  'lang.setAuto': 'The board now follows the system language (currently {lang}).',
371  'lang.notSaved': 'The board speaks {lang} in this session, but the setting could not be saved ({reason}); the next start goes back to the saved one.',
372  'lang.usage': 'Usage: /pm lang en, /pm lang zh-TW or /pm lang auto (follow the system). Now: {lang}.',
373
374  'board.cannotRead': 'Could not read the session list: {error}',
375  'board.title': '# Project status: {project}',
376  'board.path': 'Path: {path}',
377  'board.counts':
378    'Sessions {total} (PM not counted) | Waiting on you {asking} | CI failed {failed} | Waiting on CI {ci} | Not released {unreleased} | Stopped · unfinished {open} | Running {running} | In the background {waiting} | Ended {done} | Idle {stale}',
379  'board.progress': 'Work items done {done}/{total} (progress counts work items; a session ticks off its own task list when it wraps up, which does not mean the work is done)',
380  'board.name': '"{title}" ({id})',
381  'board.attention.asking': '- [Waiting on you] {name} needs the user to decide: {said}',
382  'board.attention.open': '- [Stopped · unfinished] {name} stopped with {n} {n|step|steps} left on its task list: {steps}',
383  'board.attention.ci': "- [Waiting on CI · unfinished] {name} last said CI has not finished, so it is not done: {said}",
384  'board.attention.unreleased': '- [Not released · unfinished] {name} last said its work is not delivered yet ({kinds}), so it is not done: {said}',
385  'board.attention.failed': '- [CI failed · needs fixing] {name} CI on GitHub ({target}) failed and needs a fix and a new run: {said}',
386  'board.attention.failedTarget': 'latest run',
387  'board.attention.caveat': '- [Follow up] {name} says in its recap that something is left: {said}',
388  'board.attention.pr': '- [PR open] {name}',
389  'board.attention.unassigned': '- [Unassigned] Work item {code} "{text}" has no owning session yet',
390  'board.attention.lost': '- [Owner gone] The owning session {owner} of work item {code} "{text}" is no longer listed (perhaps archived); check whether it was finished',
391  'board.owner.unassigned': 'unassigned',
392  'board.owner.gone': '{id} (no longer listed)',
393  'board.owner.status': '{title} ({tag}{elsewhere})',
394  'board.owner.elsewhere': ' · session of another project',
395  'board.section.attention': '## Needs attention',
396  'board.section.items': '## Work items',
397  'board.none': '(none)',
398  'board.allDone': '(all done)',
399  'board.item': '- {code} (id {id}) [{status}] {text} | owner: {owner}{note}',
400  'board.itemNote': ' | note: {note}',
401  'board.flag.pr': 'PR open',
402  'board.flag.pinned': 'pinned',
403  'board.lastActive': 'Last active {ago}{flags}',
404  'board.flags': ' | {flags}',
405  'board.tasks': 'Tasks {done}/{total}: {steps}',
406  'board.prompt': 'Latest instruction: {text}',
407  'board.recap': 'Recap: {text}',
408  'board.answer': 'Last report: {text}',
409  'board.ask': 'Question to decide: {text}',
410  'board.background': '{n} background {n|command|commands} running: {tasks}',
411  'board.backgroundTask': '{label} (started {ago})',
412  'board.untracked': '(No record: this session was open before the mod was installed) Last message: {said}',
413  'board.readFailed': 'could not read it: {error}',
414  'board.reading': 'reading',
415  'board.hidden': '{n} more ended {n|session|sessions} with nothing left {n|is|are} not listed; pass include_done: true to see all.',
416
417  'blocker.ciFailedOn': 'has CI failing on GitHub ({target})',
418  'blocker.ciRunning': 'last said CI has not finished',
419  'blocker.undelivered': 'last said its work is not delivered yet ({kinds})',
420  'blocker.quoted': 'The owning session "{title}" {why} ({said}).',
421  'blocker.lastReport': 'The owning session "{title}" {why} (last report: {said}).',
422  'blocker.plain': 'The owning session "{title}" {why}.',
423  'blocker.why.ci': 'is waiting on CI',
424  'blocker.why.failed': 'has CI failing on GitHub',
425  'blocker.why.unreleased': 'last said its work is not delivered yet (not committed, pushed or released)',
426  'blocker.why.running': 'is still running',
427  'blocker.why.waiting': 'still has work running in the background',
428  'blocker.why.asking': 'is waiting on the user',
429  'blocker.why.open': 'has steps left on its task list',
430
431  'ledger.empty': 'No work items yet.',
432  'ledger.row': '{code} (id {id}) [{status}] {text} | owner: {owner}{note}',
433  'ledger.unassigned': 'unassigned',
434  'ledger.noProject': 'This session belongs to no project, so it has no work items.',
435  'ledger.needText': 'add needs text.',
436  'ledger.added': 'Added work item {code} (id {id}).',
437  'ledger.notFound': 'No work item with id {id}.',
438  'ledger.refused': "Cannot mark {code} done: {blocker}\nClose it once CI has passed and it is deployed; if the user has explicitly confirmed it can be closed, pass force: true and write what they confirmed in note.",
439  'ledger.updated': 'Updated work item {code}.',
440  'ledger.badAction': 'action must be add, update or list.',
441
442  'band.title': 'Project PM',
443  'band.noPm': ' "{project}" has no PM yet',
444  'band.starting': 'Starting…',
445  'band.start': 'Start a PM session',
446  'band.claim': 'Make this session the PM',
447  'band.skip': 'Not now',
448
449  'pane.syncing': 'Syncing sessions…',
450  'pane.cannotRead': 'Could not read the session list: {error}',
451  'pane.projects': '{n} {n|project|projects}',
452  'pane.pmSelf': 'PM: this session',
453  'pane.pmOther': 'PM: assigned',
454  'pane.pmNone': 'PM: none',
455  'pane.ratio': '{percent}% done',
456  'pane.noData': 'No data yet',
457  'pane.itemsDone': 'Work items done {done}/{total}',
458  'pane.stepsDone': 'Session steps done {done}/{total} (no work items yet)',
459  'pane.noItems': 'No work items yet',
460  'pane.itemsOpen': '{n} open',
461  'pane.itemsUnassigned': ' · {n} unassigned',
462  'pane.kpiRunning': 'Running',
463  'pane.kpiAsking': 'Waiting on you',
464  'pane.kpiOpen': 'Not finished',
465  'pane.portfolio': 'PORTFOLIO',
466  'pane.project': 'PROJECT',
467  'pane.allProjects': 'All projects',
468  'pane.sessionsCount': '{n} {n|session|sessions} · {role}',
469  'pane.scopeProject': 'This project',
470  'pane.scopeAll': 'All',
471  'pane.confirmArchiveWarn': 'Archive it? {warn}',
472  'pane.confirmArchive': 'Archive this session?',
473  'pane.archive': 'Archive',
474  'pane.archiving': 'Archiving…',
475  'pane.cancel': 'Cancel',
476  'pane.question': 'Question: {text}',
477  'pane.instruction': 'Instruction: {text}',
478  'pane.noReport': 'Nothing reported yet',
479  'pane.cannotReadTranscript': 'Could not read the transcript: {error}',
480  'pane.reading': 'Reading…',
481  'pane.steps': 'Steps {done}/{total}',
482  'pane.prOpen': 'PR open',
483  'pane.untracked': 'No record',
484  'pane.warnSteps': '{n} {n|step|steps} left',
485  'pane.warnPr': 'PR still open',
486  'pane.warnPinned': 'pinned',
487  'pane.moreSteps': ' (+{n} more)',
488  'pane.caveat': 'Follow up',
489  'pane.elsewhere': ' · other project',
490  'pane.ownerNone': 'Unassigned',
491  'pane.owner': 'Owner {title} · {status}{elsewhere}',
492  'pane.ownerGone': 'Owning session is no longer listed',
493  'pane.projectCounts': '{live} active · {ended} ended{pm}',
494  'pane.projectPm': ' · PM',
495  'pane.noLive': 'No active sessions.',
496  'pane.noSessions': 'No other sessions in this project.',
497  'pane.noPm': 'This project has no PM yet',
498  'pane.items': 'WORK ITEMS',
499  'pane.sessions': 'SESSIONS',
500  'pane.sessionsMeta': '{live} active · {ended} ended',
501  'pane.hideEnded': 'Hide ended sessions',
502  'pane.showEnded': 'Show ended sessions ({n})',
503  'pane.synced': 'Synced {ago} · updates every 30 s',
504  'pane.refresh': 'Sync now',
505}
506
507export const MESSAGES: Record<Lang, Record<MessageKey, string>> = { en, 'zh-TW': zhTW }
508
509/** The language the system runs in, as the JavaScript runtime reports it (Windows: the display language). */
510export function systemLocale(): string {
511  try {
512    return Intl.DateTimeFormat().resolvedOptions().locale
513  } catch {
514    return ''
515  }
516}
517
518/**
519 * The language to speak: the `language` setting when it names one (`en`, `zh-TW`), else the system's.
520 * Any Chinese locale reads Traditional Chinese, Simplified included (zh-CN, zh-Hans, zh-SG): the same
521 * language in another script reads far more easily than English. Everything else reads English.
522 */
523export function resolveLang(setting: unknown, locale: string): Lang {
524  if (setting === 'en' || setting === 'zh-TW') return setting
525
526  return /^zh(?:[-_]|$)/i.test(locale) ? 'zh-TW' : 'en'
527}
528
529let current: Lang = resolveLang(undefined, systemLocale())
530
531export function getLang(): Lang {
532  return current
533}
534
535export function setLang(lang: Lang) {
536  current = lang
537}
538
539/** The message `key` in the current language, `{name}` filled from `params`, `{n|one|other}` picked by `n`. */
540export function t(key: MessageKey, params: Record<string, string | number> = {}): string {
541  return MESSAGES[current][key].replace(/\{(\w+)(?:\|([^|}]*)\|([^}]*))?\}/g, (whole, name: string, one?: string, other?: string) => {
542    const value = params[name]
543    if (one !== undefined) return Number(value) === 1 ? one : (other ?? '')
544
545    return value === undefined ? whole : String(value)
546  })
547}
548
549/** The names a message takes: `{name}` and `{name|one|other}` alike. */
550export function placeholders(text: string): string[] {
551  return [...new Set([...text.matchAll(/\{(\w+)(?:\|[^}]*)?\}/g)].map(m => m[1]!))].sort()
552}
553
hooks/view.tsx 480 lines
1// The PM pane and the "no PM yet" band. Status reads by a colored mark and a
2// short label; numbers are tabular; actions stay out of sight until hovered.
3
4import { atom, read, update } from 'claude-code'
5import type { ElementConstructor, EngineInterface, RenderInputOf, RenderSurface, SvgProps } from 'claude-code'
6
7import type { CiCheck, LastWord, LedgerItem, Me, Notice, ProjectFile, PushCheck, Scope, Session, Sessions, Step, Track } from '../types'
8import { STATUS, ago, caveatOf, clip, groupProjects, isActive, itemCode, liveTracks, openSteps, projectKey, statusOf } from './sessions'
9import type { Project, Status } from './sessions'
10import { archive, claim, dismissBand, refresh, startPmSession } from './actions'
11import { t } from './messages'
12
13// The engine reads each module's state refs from its own source: these name the same values as the other modules'.
14const me = atom({ plugin: 'project-pm', key: 'me' } as const, null as Me | null)
15const sessions = atom({ plugin: 'project-pm', key: 'sessions' } as const, { list: [], fetchedAt: 0, error: null } as Sessions)
16const tracks = atom({ plugin: 'project-pm', key: 'tracks' } as const, {} as Record<string, Track>)
17const lastWords = atom({ plugin: 'project-pm', key: 'lastWords' } as const, {} as Record<string, LastWord>)
18const ciChecks = atom({ plugin: 'project-pm', key: 'ciChecks' } as const, {} as Record<string, CiCheck>)
19const pushChecks = atom({ plugin: 'project-pm', key: 'pushChecks' } as const, {} as Record<string, PushCheck>)
20const projects = atom({ plugin: 'project-pm', key: 'projects' } as const, {} as Record<string, ProjectFile>)
21const scope = atom({ plugin: 'project-pm', key: 'scope' } as const, 'project' as Scope)
22const confirm = atom({ plugin: 'project-pm', key: 'confirm' } as const, null as string | null)
23const busy = atom({ plugin: 'project-pm', key: 'busy' } as const, null as string | null)
24const notice = atom({ plugin: 'project-pm', key: 'notice' } as const, null as Notice | null)
25const isBandHidden = atom({ plugin: 'project-pm', key: 'isBandHidden' } as const, false)
26const isEndedShown = atom({ plugin: 'project-pm', key: 'isEndedShown' } as const, false)
27
28const TRACK_HEX = '#9ca3af'
29const ACCENT_HEX = '#d97757'
30
31// ── SVG marks: no text inside, mid-tone hues that read on light and dark ──
32
33function dotSvg(hex: string, isPulsing: boolean): string {
34  const pulse = isPulsing
35    ? `<circle cx="6" cy="6" r="3" fill="none" stroke="${hex}" stroke-width="1.2"><animate attributeName="r" values="3;5.6" dur="1.8s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.9;0" dur="1.8s" repeatCount="indefinite"/></circle>`
36    : ''
37
38  return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">${pulse}<circle cx="6" cy="6" r="3" fill="${hex}"/></svg>`
39}
40
41function ringSvg(ratio: number): string {
42  const r = 17
43  const c = 2 * Math.PI * r
44  const arc = Math.max(0, Math.min(1, ratio)) * c
45
46  return `<svg xmlns="http://www.w3.org/2000/svg" width="42" height="42" viewBox="0 0 42 42"><circle cx="21" cy="21" r="${r}" fill="none" stroke="${TRACK_HEX}" stroke-opacity="0.22" stroke-width="4"/><circle cx="21" cy="21" r="${r}" fill="none" stroke="${ACCENT_HEX}" stroke-width="4" stroke-linecap="round" stroke-dasharray="${arc.toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 21 21)"/></svg>`
47}
48
49/** One segment per step: done in the status hue, current at half, the rest as track. */
50function stepsSvg(plan: Step[], hex: string): string {
51  const width = 320
52  const gap = plan.length > 24 ? 1 : 3
53  const seg = (width - gap * (plan.length - 1)) / plan.length
54  const rects = plan
55    .map((step, i) => {
56      const x = (i * (seg + gap)).toFixed(2)
57      const fill = step.status === 'pending' ? TRACK_HEX : hex
58      const opacity = step.status === 'completed' ? 1 : step.status === 'in_progress' ? 0.5 : 0.22
59
60      return `<rect x="${x}" y="0" width="${seg.toFixed(2)}" height="4" rx="2" fill="${fill}" fill-opacity="${opacity}"/>`
61    })
62    .join('')
63
64  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="4" viewBox="0 0 ${width} 4">${rects}</svg>`
65}
66
67// ── The band ──
68
69export async function renderBand<R>($: EngineInterface, e: RenderInputOf<'AbovePrompt', RenderSurface>, pass: () => Promise<R>) {
70  const self = await read($, me)
71  if (e.props.hasSurvey || !self || self.isPm || self.isScratch || !self.id || (await read($, isBandHidden))) return pass()
72  const project = (await read($, projects))[self.projectKey]
73  if (!project || project.pmId || project.isDismissed) return pass()
74  const { Box, Text, Button } = $.ui.resolve(e)
75  const isStarting = (await read($, busy)) === 'start-pm'
76
77  return (
78    <Box flexDirection="row" flexWrap="wrap" columnGap="sm" rowGap="xs" alignItems="center">
79      <Text size="footnote">
80        <Text color="claude" weight="semibold">
81          {t('band.title')}
82        </Text>
83        <Text dimColor>{t('band.noPm', { project: self.projectName })}</Text>
84      </Text>
85      <Button key="pm:start" variant="primary" label={isStarting ? t('band.starting') : t('band.start')} onPress={() => (isStarting ? undefined : startPmSession($))} />
86      <Button key="pm:here" variant="secondary" label={t('band.claim')} onPress={() => void claim($)} />
87      <Button key="pm:no" plain dimColor label={t('band.skip')} onPress={() => dismissBand($)} />
88    </Box>
89  )
90}
91
92// ── The pane ──
93
94export async function renderPane($: EngineInterface, e: RenderInputOf<'Pane', RenderSurface>) {
95  const els = $.ui.resolve(e)
96  const { Box, Text, Button } = els
97  const Svg = 'Svg' in els ? (els as unknown as { Svg: ElementConstructor<SvgProps> }).Svg : null
98  const self = await read($, me)
99  const state = await read($, sessions)
100  const words = await read($, lastWords)
101  const all = liveTracks(state.list, await read($, tracks), words)
102  const ci = await read($, ciChecks)
103  const push = await read($, pushChecks)
104  const files = await read($, projects)
105  const view = await read($, scope)
106  const asking = await read($, confirm)
107  const working = await read($, busy)
108  const note = await read($, notice)
109  const isEndedOpen = await read($, isEndedShown)
110  const now = await $.clock.now()
111
112  if (state.fetchedAt === 0 && !state.error) {
113    return (
114      <Text size="footnote" dimColor>
115        {t('pane.syncing')}
116      </Text>
117    )
118  }
119  if (state.error && state.list.length === 0) {
120    return (
121      <Text size="footnote" color="error" wrap="wrap">
122        {t('pane.cannotRead', { error: state.error })}
123      </Text>
124    )
125  }
126
127  const svg = (key: string, source: string, alt: string, size?: { width?: number; height?: number }, isInteractive = false) =>
128    Svg ? <Svg key={key} source={source} alt={alt} width={size?.width} height={size?.height} isInteractive={isInteractive || undefined} /> : null
129
130  const statusOfOne = (one: Session) => statusOf(one, all[one.id], words[one.id], now, ci[one.id], push[one.id])
131  const groups = groupProjects(state.list)
132  const isAll = view === 'all' || !self || self.isScratch
133  const shown: Project[] = isAll
134    ? groups
135    : [groups.find(p => p.key === self.projectKey) ?? { key: self.projectKey, name: self.projectName, path: self.projectPath, sessions: [] }]
136  const everyone = shown.flatMap(p => p.sessions)
137  const ledgers: { project: Project; item: LedgerItem }[] = shown.flatMap(project =>
138    (files[project.key]?.ledger ?? []).filter(i => i.status === 'open').map(item => ({ project, item })),
139  )
140
141  // ── Header and KPIs ──
142
143  // Progress is the work the person asked for (the ledger), not the sessions' own
144  // checklists: a session ticks its list off when its turn ends, done or not.
145  const items = shown.flatMap(project => (files[project.key]?.ledger ?? []).filter(i => i.status !== 'dropped'))
146  const itemsDone = items.filter(i => i.status === 'done').length
147  const unassigned = ledgers.filter(({ item }) => !item.owner).length
148  const steps = everyone.flatMap(one => all[one.id]?.plan ?? [])
149  const stepsDone = steps.filter(s => s.status === 'completed').length
150  const isByItems = items.length > 0
151  const ratio = isByItems ? itemsDone / items.length : steps.length ? stepsDone / steps.length : 0
152  const hasRatio = isByItems || steps.length > 0
153  const count = (s: Status) => everyone.filter(one => statusOfOne(one) === s).length
154  const pmFile: ProjectFile | undefined = !isAll && self ? files[self.projectKey] : undefined
155  const role = isAll
156    ? t('pane.projects', { n: shown.length })
157    : pmFile?.pmId
158      ? pmFile.pmId === self?.id
159        ? t('pane.pmSelf')
160        : t('pane.pmOther')
161      : t('pane.pmNone')
162
163  const metric = (key: string, value: number, label: string, color: string) => (
164    <Box key={key} flexDirection="column" width="33%" paddingX="sm">
165      <Text size="title" weight="semibold" tabular color={value ? color : undefined} dimColor={!value}>
166        {String(value)}
167      </Text>
168      <Text size="caption" dimColor wrap="truncate-end">
169        {label}
170      </Text>
171    </Box>
172  )
173
174  const progress = (
175    <Box key="kpi" flexDirection="column" rowGap="sm" borderStyle="round" borderDimColor paddingX="md" paddingY="sm">
176      <Box flexDirection="row" alignItems="center" columnGap="md">
177        {svg('ring', ringSvg(ratio), hasRatio ? t('pane.ratio', { percent: Math.round(ratio * 100) }) : t('pane.noData'), { width: 42, height: 42 })}
178        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
179          <Text size="title" weight="semibold" tabular>
180            {hasRatio ? `${Math.round(ratio * 100)}%` : '—'}
181          </Text>
182          <Text size="caption" dimColor wrap="truncate-end">
183            {isByItems
184              ? t('pane.itemsDone', { done: itemsDone, total: items.length })
185              : steps.length
186                ? t('pane.stepsDone', { done: stepsDone, total: steps.length })
187                : t('pane.noItems')}
188          </Text>
189          {isByItems && (ledgers.length > 0 || unassigned > 0) && (
190            <Text size="caption" wrap="truncate-end">
191              <Text dimColor>{t('pane.itemsOpen', { n: ledgers.length })}</Text>
192              {unassigned > 0 && <Text color="warning">{t('pane.itemsUnassigned', { n: unassigned })}</Text>}
193            </Text>
194          )}
195        </Box>
196      </Box>
197      <Box flexDirection="row">
198        {metric('kpi:running', count('running') + count('waiting'), t('pane.kpiRunning'), 'success')}
199        {metric('kpi:asking', count('asking'), t('pane.kpiAsking'), 'warning')}
200        {metric('kpi:open', count('open') + count('ci') + count('failed') + count('unreleased'), t('pane.kpiOpen'), 'claude')}
201      </Box>
202    </Box>
203  )
204
205  const header = (
206    <Box key="header" flexDirection="column" rowGap="sm">
207      <Box flexDirection="row" justifyContent="space-between" alignItems="flex-start" columnGap="sm">
208        <Box flexDirection="column" flexShrink={1}>
209          <Text size="caption" weight="medium" color="claude">
210            {isAll ? t('pane.portfolio') : t('pane.project')}
211          </Text>
212          <Text size="heading" weight="semibold" wrap="truncate-end">
213            {isAll ? t('pane.allProjects') : (shown[0]?.name ?? '')}
214          </Text>
215          <Text size="caption" dimColor tabular wrap="truncate-end">
216            {t('pane.sessionsCount', { n: everyone.length, role })}
217          </Text>
218        </Box>
219        <Box flexDirection="row" columnGap="xs" flexShrink={0}>
220          <Button key="scope:project" label={t('pane.scopeProject')} variant={view === 'project' ? 'primary' : 'secondary'} onPress={() => update($, scope, () => 'project')} />
221          <Button key="scope:all" label={t('pane.scopeAll')} variant={view === 'all' ? 'primary' : 'secondary'} onPress={() => update($, scope, () => 'all')} />
222        </Box>
223      </Box>
224      {progress}
225    </Box>
226  )
227
228  // ── Pieces ──
229
230  const sectionTitle = (key: string, title: string, meta: string) => (
231    <Box key={key} flexDirection="row" justifyContent="space-between" alignItems="center" marginTop="md">
232      <Text size="caption" weight="semibold" dimColor>
233        {title}
234      </Text>
235      <Text size="caption" dimColor tabular>
236        {meta}
237      </Text>
238    </Box>
239  )
240
241  const mark = (key: string, status: Status) =>
242    svg(key, dotSvg(STATUS[status].hex, status === 'running'), STATUS[status].label, { width: 12, height: 12 }, status === 'running') ?? (
243      <Text key={key} color={STATUS[status].color}>
244        ●
245      </Text>
246    )
247
248  const archiveControls = (one: Session, status: Status, warn: string) =>
249    asking === one.id ? (
250      <Box key={`confirm:${one.id}`} flexDirection="row" flexWrap="wrap" columnGap="sm" rowGap="xs" alignItems="center">
251        <Text size="caption" color="warning">
252          {warn ? t('pane.confirmArchiveWarn', { warn }) : t('pane.confirmArchive')}
253        </Text>
254        <Button key={`yes:${one.id}`} variant="primary" label={t('pane.archive')} onPress={() => archive($, one)} />
255        <Button key={`no:${one.id}`} variant="secondary" label={t('pane.cancel')} onPress={() => update($, confirm, () => null)} />
256      </Box>
257    ) : status === 'running' ? null : (
258      <Box key={`actions:${one.id}`} flexDirection="row" display="none" hover={{ scope: `card:${one.id}`, display: 'flex' }}>
259        <Button
260          key={`archive:${one.id}`}
261          plain
262          dimColor
263          label={working === one.id ? t('pane.archiving') : t('pane.archive')}
264          onPress={() => (working ? undefined : update($, confirm, () => one.id))}
265        />
266      </Box>
267    )
268
269  const summaryOf = (track: Track | undefined, one: Session) => {
270    if (track) {
271      if (track.ask) return t('pane.question', { text: track.ask })
272
273      return track.recap || track.lastAnswer || (track.lastPrompt ? t('pane.instruction', { text: track.lastPrompt }) : t('pane.noReport'))
274    }
275    const word = words[one.id]
276    if (word?.error) return t('pane.cannotReadTranscript', { error: word.error })
277
278    return word ? word.said || t('pane.noReport') : t('pane.reading')
279  }
280
281  const card = (one: Session) => {
282    const status = statusOfOne(one)
283    const look = STATUS[status]
284    const track = all[one.id]
285    const plan = track?.plan ?? []
286    const left = openSteps(track)
287    const caveat = status === 'running' ? null : caveatOf(track, push[one.id])
288    const summary = summaryOf(track, one)
289    const isPm = files[projectKey(one.cwd)]?.pmId === one.id
290    const meta = [
291      plan.length ? t('pane.steps', { done: plan.length - left.length, total: plan.length }) : '',
292      one.hasOpenPr ? t('pane.prOpen') : '',
293      isPm ? 'PM' : '',
294      track ? '' : t('pane.untracked'),
295    ].filter(Boolean)
296    const warn = [left.length ? t('pane.warnSteps', { n: left.length }) : '', one.hasOpenPr ? t('pane.warnPr') : '', one.isPinned ? t('pane.warnPinned') : '']
297      .filter(Boolean)
298      .join(t('list.joiner'))
299    const current = left.find(s => s.status === 'in_progress') ?? left[0]
300
301    return (
302      <Box
303        key={`card:${one.id}`}
304        flexDirection="column"
305        rowGap="xs"
306        borderStyle="round"
307        borderDimColor
308        paddingX="md"
309        paddingY="sm"
310        hover={{ scope: `card:${one.id}`, borderColor: look.color, borderDimColor: false }}
311      >
312        <Box flexDirection="row" alignItems="center" columnGap="sm">
313          {mark(`dot:${one.id}`, status)}
314          <Box flexGrow={1} flexShrink={1}>
315            <Text weight="semibold" wrap="truncate-end">
316              {one.title}
317            </Text>
318          </Box>
319          <Text size="caption" dimColor tabular>
320            {ago(one.lastActivityAt, now)}
321          </Text>
322        </Box>
323        <Text size="caption" wrap="truncate-end">
324          <Text color={look.color} weight="medium">
325            {look.label}
326          </Text>
327          {meta.length > 0 && <Text dimColor>{` · ${meta.join(' · ')}`}</Text>}
328        </Text>
329        {plan.length > 0 && svg(`steps:${one.id}`, stepsSvg(plan, look.hex), t('pane.steps', { done: plan.length - left.length, total: plan.length }))}
330        {current && (
331          <Text size="footnote" wrap="truncate-end" color={status === 'open' ? 'claude' : undefined}>
332            {`▸ ${current.content}${left.length > 1 ? t('pane.moreSteps', { n: left.length - 1 }) : ''}`}
333          </Text>
334        )}
335        {caveat ? (
336          <Text size="footnote" wrap="truncate-end">
337            <Text color="warning" weight="medium">
338              {t('pane.caveat')}
339            </Text>
340            <Text dimColor>{` ${clip(caveat, 160)}`}</Text>
341          </Text>
342        ) : (
343          <Text size="footnote" dimColor wrap="truncate-end">
344            {clip(summary, 160)}
345          </Text>
346        )}
347        {archiveControls(one, status, warn)}
348      </Box>
349    )
350  }
351
352  const endedRow = (one: Session) => {
353    const status = statusOfOne(one)
354
355    return (
356      <Box key={`ended:${one.id}`} flexDirection="column" rowGap="xs" paddingX="md" paddingY="xs" hover={{ scope: `card:${one.id}` }}>
357        <Box flexDirection="row" alignItems="center" columnGap="sm">
358          {mark(`dot:${one.id}`, status)}
359          <Box flexGrow={1} flexShrink={1}>
360            <Text size="footnote" wrap="truncate-end">
361              {one.title}
362            </Text>
363          </Box>
364          <Text size="caption" dimColor tabular>
365            {ago(one.lastActivityAt, now)}
366          </Text>
367        </Box>
368        {archiveControls(one, status, one.hasOpenPr ? t('pane.warnPr') : '')}
369      </Box>
370    )
371  }
372
373  const ledgerRow = ({ project, item }: { project: Project; item: LedgerItem }) => {
374    // An owner working from another project's folder is still there: only one gone from the list is lost.
375    const owner = item.owner ? state.list.find(s => s.id === item.owner) : undefined
376    const elsewhere = owner && !project.sessions.includes(owner) ? t('pane.elsewhere') : ''
377    const ownerText = !item.owner
378      ? t('pane.ownerNone')
379      : owner
380        ? t('pane.owner', { title: owner.title, status: STATUS[statusOfOne(owner)].label, elsewhere })
381        : t('pane.ownerGone')
382
383    return (
384      <Box key={`ledger:${project.key}:${item.id}`} flexDirection="row" columnGap="sm" alignItems="flex-start" paddingY="xs">
385        <Text size="caption" mono dimColor>
386          {itemCode(item.id)}
387        </Text>
388        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
389          <Text size="footnote" wrap="wrap">
390            {item.text}
391          </Text>
392          <Text size="caption" color={owner ? undefined : 'warning'} dimColor={!!owner} wrap="truncate-end">
393            {isAll ? `${project.name} · ${ownerText}` : ownerText}
394          </Text>
395        </Box>
396      </Box>
397    )
398  }
399
400  const byRank = (a: Session, b: Session) => STATUS[statusOfOne(a)].rank - STATUS[statusOfOne(b)].rank
401  // A session that owns open work stays in sight, whatever it is doing.
402  const owners = new Set(ledgers.map(({ item }) => item.owner).filter(Boolean))
403  const active = (list: Session[]) =>
404    list.filter(one => isActive(statusOfOne(one)) || !!caveatOf(all[one.id], push[one.id]) || one.hasOpenPr || owners.has(one.id)).sort(byRank)
405  const ended = (list: Session[]) => list.filter(one => !active(list).includes(one))
406
407  const sessionsBlock = (project: Project) => {
408    const live = active(project.sessions)
409    const rest = ended(project.sessions)
410
411    return (
412      <Box key={`sessions:${project.key}`} flexDirection="column" rowGap="sm">
413        {isAll && (
414          <Box flexDirection="row" justifyContent="space-between" alignItems="center" marginTop="md">
415            <Text weight="semibold" wrap="truncate-end">
416              {project.name}
417            </Text>
418            <Text size="caption" dimColor tabular>
419              {t('pane.projectCounts', { live: live.length, ended: rest.length, pm: files[project.key]?.pmId ? t('pane.projectPm') : '' })}
420            </Text>
421          </Box>
422        )}
423        {live.length === 0 && (
424          <Text size="footnote" dimColor>
425            {project.sessions.length ? t('pane.noLive') : t('pane.noSessions')}
426          </Text>
427        )}
428        {live.map(card)}
429        {rest.length > 0 && (isEndedOpen ? rest.map(endedRow) : null)}
430      </Box>
431    )
432  }
433
434  const endedTotal = shown.reduce((n, p) => n + ended(p.sessions).length, 0)
435  const liveTotal = everyone.length - endedTotal
436  const canClaim = !!self?.id && !self.isScratch && !self.isPm && !isAll && !pmFile?.pmId
437
438  return (
439    <Box flexDirection="column" rowGap="xs">
440      {header}
441      {note && (
442        <Box key="notice" borderStyle="round" borderColor={note.tone === 'bad' ? 'error' : 'success'} paddingX="sm" paddingY="xs" marginTop="sm">
443          <Text size="footnote" wrap="wrap">
444            {note.text}
445          </Text>
446        </Box>
447      )}
448      {canClaim && (
449        <Box key="claim" flexDirection="row" flexWrap="wrap" columnGap="sm" alignItems="center" marginTop="sm">
450          <Text size="footnote" dimColor>
451            {t('pane.noPm')}
452          </Text>
453          <Button key="claim" variant="secondary" label={t('band.claim')} onPress={() => void claim($)} />
454        </Box>
455      )}
456      {ledgers.length > 0 && sectionTitle('h:ledger', t('pane.items'), t('pane.itemsOpen', { n: ledgers.length }))}
457      {ledgers.map(ledgerRow)}
458      {sectionTitle('h:sessions', t('pane.sessions'), t('pane.sessionsMeta', { live: liveTotal, ended: endedTotal }))}
459      {shown.map(sessionsBlock)}
460      {endedTotal > 0 && (
461        <Box key="ended-toggle" flexDirection="row">
462          <Button
463            key="ended:toggle"
464            plain
465            dimColor
466            label={isEndedOpen ? t('pane.hideEnded') : t('pane.showEnded', { n: endedTotal })}
467            onPress={() => update($, isEndedShown, v => !v)}
468          />
469        </Box>
470      )}
471      <Box key="footer" flexDirection="row" justifyContent="space-between" alignItems="center" marginTop="sm">
472        <Text size="caption" dimColor tabular>
473          {t('pane.synced', { ago: ago(state.fetchedAt, now) })}
474        </Text>
475        <Button key="refresh" plain dimColor label={t('pane.refresh')} onPress={() => refresh($, true)} />
476      </Box>
477    </Box>
478  )
479}
480
types/index.d.ts 145 lines
1/** One of the person's sessions, as the desktop app lists it. */
2export type Session = {
3  id: string
4  title: string
5  cwd: string
6  isRunning: boolean
7  lastActivityAt: string
8  isPinned: boolean
9  hasOpenPr: boolean
10}
11
12export type Sessions = {
13  list: Session[]
14  fetchedAt: number
15  error: string | null
16}
17
18/** Where a session's transcript left off, read from list_events for a session that keeps no track. */
19export type LastWord = {
20  said: string
21  isAsking: boolean
22  isWaiting: boolean
23  /** The session's lastActivityAt when this was read: a newer one reads again. */
24  activity: string
25  error: string | null
26}
27
28export type StepStatus = 'pending' | 'in_progress' | 'completed'
29
30export type Step = { content: string; status: StepStatus }
31
32/** What a session writes about itself after each change: its task list, its last recap and question. */
33export type Track = {
34  id: string
35  title: string
36  cwd: string
37  plan: Step[]
38  recap: string | null
39  ask: string | null
40  lastPrompt: string
41  lastAnswer: string
42  /** When the last turn ended, ms. */
43  turnAt: number
44  /** When the task list last changed, ms. */
45  planAt: number
46  /** When wrap_up last wrote the recap, ms: a turn that ends after it without its own wrap_up drops it. */
47  recapAt?: number
48  /** Background commands the session started and has not been notified of yet. */
49  background?: BackgroundTask[]
50  /** Whether the person left this session's board open (/board, the pane's close): absent while they have not said. */
51  isBoardOpen?: boolean
52}
53
54/** What git says of the commit and push a session's words leave: facts stand over words (leftOf). */
55export type PushCheck = {
56  /** The words it was checked for: other words check again. */
57  words: string
58  /** The commit the words name, or empty when the folder's HEAD was asked about. */
59  sha: string
60  /** The named commit, or a clean folder's HEAD, is on a remote branch: nothing left to push. */
61  isPushed: boolean
62  /** The folder has no uncommitted changes (`git status --porcelain` empty): nothing left to commit. */
63  isClean?: boolean
64  /** `sha` is the words' own commit (commit 是 …) and is in this repository: on no remote branch, its push is left. */
65  isOwn?: boolean
66  checkedAt: number
67}
68
69/** A command a session runs in the background (run_in_background, or moved there), until its task-notification. */
70export type BackgroundTask = {
71  id: string
72  /** What it runs: the command's description, else the command. */
73  label: string
74  /** When it started, ms. */
75  at: number
76}
77
78/** What GitHub says about the CI a session's words left running. */
79export type CiCheck = {
80  /** The words it was checked for: other words check again. */
81  words: string
82  /** What was asked: `run 123`, `commit <sha>`; empty when nothing could be named. */
83  target: string
84  state: 'pending' | 'success' | 'failure' | 'unknown'
85  checkedAt: number
86}
87
88/** Something the person asked the PM for, kept until it is done. */
89export type LedgerItem = {
90  id: number
91  text: string
92  /** The session it was handed to, a `local_…` id, or null while nobody has it. */
93  owner: string | null
94  status: 'open' | 'done' | 'dropped'
95  note: string
96  createdAt: number
97  updatedAt: number
98}
99
100/** A project's PM and ledger, one file per project. */
101export type ProjectFile = {
102  key: string
103  name: string
104  path: string
105  pmId: string | null
106  isDismissed: boolean
107  ledger: LedgerItem[]
108  nextId: number
109}
110
111/** This session: its desktop id, its project, whether it is that project's PM. */
112export type Me = {
113  id: string | null
114  title: string
115  projectKey: string
116  projectName: string
117  projectPath: string
118  isScratch: boolean
119  isPm: boolean
120}
121
122export type Notice = { tone: 'good' | 'bad'; text: string }
123
124export type Scope = 'project' | 'all'
125
126declare module 'claude-code' {
127  interface PluginState {
128    'project-pm': {
129      me: Me | null
130      sessions: Sessions
131      tracks: Record<string, Track>
132      lastWords: Record<string, LastWord>
133      ciChecks: Record<string, CiCheck>
134      pushChecks: Record<string, PushCheck>
135      projects: Record<string, ProjectFile>
136      scope: Scope
137      confirm: string | null
138      busy: string | null
139      notice: Notice | null
140      isBandHidden: boolean
141      isEndedShown: boolean
142    }
143  }
144}
145