SLOPSHOPPER

session-note

A mod plugin for Claude Code — gives every session a note where you and the agent jot down what to do or check, and tick it off together.

panebandguardcommandtoasttool
★ 1v1.1.1MITupdated 2026-10-20angar2/session-note/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-note
│ ┃ Notes ✕ › fix the failing auth test and add an audit log call │ ┃ Notes │ ┃ tab-todo tab-process ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ──────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ No open items │ ┃ ──────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ──────────────────────────────────────────── │ ┃ + New item › /note │ │ ☑ 0 Close ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☑ 0 Close
Pane · Notes
Notes tab-todo tab-process ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── No open items ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── + New item
README

session-note

A mod plugin for Claude Code — gives every session a note where you and the agent jot down what to do or check, and tick it off together. For multi-step work, the session lays out a process and shows which step it is on.

한국어

session-note demo: given a bug to fix, the session lays out a process and shows the current step on the right of the band, proposes a postponed task as a to-do that gets added, then the process tab in the pane shows the steps, and after "Remove step 4" the step is gone and the steps are renumbered

Overview

session-note is a mod plugin for Claude Code. Mods let a plugin draw its own parts right inside the Claude Code screen, so the note is not a separate window: it sits just above the prompt and in a pane beside the terminal.

Work with an agent for a while and you will hear yourself say things like:

  • "Let's skip that for now and come back to it."
  • "I should double-check that this is right."
  • "Once this is done, let's run the tests too."

In the moment you are sure you will remember. But as the conversation grows, these lines scroll away and are gone, and the agent will not bring them back up unless you do. That is how the things you put off quietly get forgotten.

session-note gives every session a small note. Put the things you postponed and the things to check in its to-do list, and tick them off when they are done. The note always sits right above the prompt, and the agent reads the same note and keeps track with you.

When you hand the session multi-step work, it lays out the order as a process in the note. A process is a roadmap the session writes itself and keeps current as the work moves along. A glance above the prompt tells you which step it is on, and when you come back from something else you can see right away how far it got.

Features

  • Open items stay in sight. The number of open to-dos is always shown above the prompt, so you take one more look before you close the session.
  • The agent proposes items. When "let's do that later" comes up, the agent proposes adding it as a to-do. You just press a button.
  • The agent tells you what is finished. When it wraps up a piece of work, the agent goes over the to-dos and proposes completing the items that look done.
  • You decide on to-dos. The agent only proposes to-dos. It never changes them until you press a button or tell it to.
  • The current step stays in sight. For multi-step work, the session lays out a process and shows the current step above the prompt. The pane shows the steps behind and ahead at a glance.
  • Change the process by asking. The session writes and updates the process itself as it works. To change it, just say "Remove step 3" or "Put this before step 2", and it changes right away, renumbered.
  • The session stays light. The note is not sent to the agent with every reply. Only what changed is passed along, briefly, when it changes.

Installation

Type these two lines inside Claude Code:

/plugin marketplace add angar2/session-note
/plugin install session-note@angar2
  • The first line registers this repository as a place to get plugins from.
  • The second line installs the plugin. Every session you open from then on gets a note.

Usage

The band

The line just above the prompt (the band) has two areas, left and right.

 ☑ 2                                                  ≡ │ ◼ 2.1 Read the logs  Open
  • ☑ 2 on the left is the number of open to-dos. When the agent makes a proposal, it shows up next to it after a │.
  • ≡ │ ◼ 2.1 Read the logs on the right is the process step the session is working on. It is empty when no step is in progress.
  • Open at the right end opens the pane. It stays in place even when a proposal comes in, and reads Close while the pane is open.
  • In a narrow window, the step title shortens with … first. Symbols, numbers and buttons always stay.

Accepting proposals

Say "let's check token expiry handling later" during the conversation, and the agent puts a proposal on the left of the band.

 ☑ 2 │ [+]  Check token expiry [Add] [Dismiss]        ≡ │ ◼ 2.1 Read the logs  Open
  • The blue [+] means "add this as a new item?"
  • Press [Add] to put it in the note, or [Dismiss] if you don't need it.
  • The proposal stays on the band until you answer it, so you can finish what you were saying first.

Completing items

When that work is finished, the agent checks it against the note and proposes completing it.

 ☑ 2 │ [✓]  Check token expiry [Complete] [Not yet]   ≡ │ ◼ 2.1 Read the logs  Open
  • The green [✓] means "this item looks done."
  • Press [Complete] if it is, or [Not yet] if something is left.
  • When several proposals pile up, the band shows only the counts, like [+] 2 new · [✓] 1 done. Press Open at the right end to answer them one by one in the To do tab of the pane.

Following a process

Hand the session multi-step work, like "Fix the login bug" or "Ship the 1.1.0 release", and it lays out a process and tells you in one line. It does not make one for a quick question.

 ☑ 1                                                        ≡ │ ◼ 1 Reproduce  Open
 ☑ 1                                                  ≡ │ ◼ 2.1 Read the logs  Open
  • The right of the band shows the current step with its number. It changes as soon as the session moves on to the next step.
  • When steps in progress are spread over different top-level steps, the band shows the first one and the rest as a count, like +1 more.
  • When no step is in progress, for example while the session waits for your answer, the process area is empty.

Managing to-dos in the pane

Press Open at the right end of the band, or type /note, and the pane opens on the right side of the terminal. In a narrow window it opens above the prompt.

The top of the pane has two tabs, like ☑ To do 2 ≡ Process 1. The numbers are the open to-dos and the unfinished processes. Press a tab to switch. The pane reopens on the tab you saw last.

The To do tab works like this:

  • From the top: proposals waiting for an answer, open items, + New item, and the done items.
  • To add an item yourself, press + New item, write a title and details, and press [Add]. Items you wrote are marked with ☺.
  • To complete an item, press its ☐. Press ☑ on a done item to open it again.
  • Press a title to expand it: its details, when it was added, and [Edit] [Delete].

Everything is done with the mouse. The keyboard is only for typing titles and details.

Viewing the process in the pane

Press the ≡ Process tab to see the whole process.

◼ Fix the login bug
  ◼ 1 Reproduce
  ◼ 2 Find the cause ▾
    ◼ 2.1 Read the logs
    ◻ 2.2 Form a hypothesis
  ◻ 3 Fix it
  ◻ 4 Add a test

◻ Ship 1.1.0 ▸  paused at 3

──────────────────────
Finished 1 ▸
  • The process at the top is the one in progress, and its current step is in bold.
  • The square before a step is its status: ◻ waiting, blue ◼ in progress, green ◼ done, red ◼ stopped.
  • The pane numbers the steps in order. Remove or insert a step, and the numbers update by themselves.
  • A step with sub-steps has a ▸. Press its title and it turns into ▾ and shows the sub-steps. At first only the steps in progress are expanded.
  • A process put on hold for other work folds into one line with where it stopped, like paused at 3. Press it to look inside.
  • Finished processes are not deleted. They gather under Finished N ▸ at the bottom.
  • The process tab is for viewing only. To change the process, ask the agent.

Managing the note in conversation

Ask the agent, and it does it right away, without a proposal.

To-dos

SayThe agent
"Put this in the note"adds an item
"That one is done"completes the item
"Rename that item"edits the item
"Delete that one"deletes the item
"Check the note"goes over the open items and proposes completing the finished ones

The agent never edits or deletes to-dos unless you tell it to.

Processes

SayThe agent
"Remove step 3"removes step 3 from the current process
"Put this before step 2"inserts a step before step 2
"Move step 4 after step 2"reorders the steps
"Hold the release, let's look at this bug first"lays out a new process and starts it; the one on hold remembers where it stopped
"Delete that process"deletes the process

The session updates the process itself as it works. It moves through steps and fills in sub-steps without a word, and gives a one-line reason only when it changes the main steps.

Configuration

Change these any time with /plugin configure session-note@angar2. Your notes keep their items.

SettingValuesDefaultDescription
LanguageEnglish · 한국어EnglishThe language of the band and the pane
Band backgroundNone · Gray · Olive · WineNoneThe background color of the band. Pick one if you want the band to stand out

The colors follow your terminal theme.

Sessions and storage

Notes per session

  • Each session has its own note.
  • /clear starts a new session, so you start with an empty note. The old note stays with the old session.
  • When you reopen a session with /resume, its note comes back with it.
  • After the conversation is compacted or resumed with /resume, the session gets the note's to-dos and process again and picks up from the step it was on.

Storage location

Each session's note is one JSON file:

~/.claude/plugins/data/session-note-angar2/sessions/<session id>.json

Uninstalling the plugin deletes the notes too. To keep them, add --keep-data when you uninstall:

claude plugin uninstall session-note@angar2 --keep-data

Limitations

  • Organization-managed Claude Code does not send session-start events to plugins you install yourself. There, the note does not switch on /clear or /resume.
  • /rewind rewinds the conversation but not the note. The agent can read the note again to get the latest state.
  • If you open the same session in two terminals at once, only the one that saves last is kept.

Requirements

  • Claude Code with mods (plugin function hooks). Tested on Claude Code 2.1.304.
  • Mods are still in early access, so behavior may change with new Claude Code versions.

License

MIT

Source 14 files
hooks/register.tsx 59 lines
1// session-note 모드의 훅 모듈 진입점 — 세션의 노트를 열고 띠·패널·/note·할 일 도구·과정 도구를 등록한다
2import type { EngineInterface, Register } from 'claude-code'
3
4import { drawBand } from './band'
5import { drawPane } from './pane'
6import { arrival, wholeNote } from './tell'
7import { backgroundOf, langOf, textOf, type Text } from './text'
8import { registerProcessTool, serveProcessTool } from './process-tool'
9import { registerTool, serveTool } from './todo-tool'
10import { PANE, openPane, openSession, paneClosed, readNote } from './view'
11
12export const register: Register = (on, options) => {
13  const text = textOf(langOf(options.language))
14
15  // 세션 시작과 모드가 다시 켜질 때마다: /note·할 일 도구·과정 도구 등록, 이 세션의 노트 읽기, 패널이 열려 있는지 맞추기
16  on('session.start', async ($, e, next) => {
17    await $.command.register({ name: 'note', description: text.command })
18    await registerTool($)
19    await registerProcessTool($)
20    await openSession($, text, await $.session.id())
21    return next(e)
22  })
23
24  // /note — 패널을 열고 대화창에 줄을 남기지 않는다
25  on('command.run', { command: 'note' }, async $ => {
26    await openPane($, text.notes)
27    return {}
28  })
29
30  // 패널이 닫히면(띠의 닫기·엔진의 닫기 표시) 초안을 버린다
31  on('ui.close', { id: PANE }, async ($, e, next) => {
32    const closed = await next(e)
33    await paneClosed($)
34    return closed
35  })
36
37  // 세션이 바뀌면(/clear·/resume·--fork-session) 그 세션의 노트로 갈아끼우고, 불러온 세션과 압축 직후에는 할 일과 과정을 전한다(spec AGENT.md 4·5절·6-5).
38  // 훅의 additionalContext로 넘겨야 압축·불러온 대화 뒤에 놓인다($.session.append는 압축 경계보다 앞에 놓인다 — 실제 세션에서 확인)
39  on('classic.SessionStart', async ($, e, next) => {
40    const answered = await next(e)
41    const told = await switchTo($, text, e.source, e.session_id)
42    if (told === null) return answered
43    return { ...answered, additionalContext: [...(answered.additionalContext ?? []), told] }
44  })
45
46  serveTool(on)
47  serveProcessTool(on)
48  drawBand(on, text, backgroundOf(options.bandBackground))
49  drawPane(on, text)
50}
51
52// 세션 전환·압축 때 세션에 전할 글, 없으면 null
53async function switchTo($: EngineInterface, text: Text, source: string, id: string): Promise<string | null> {
54  if (source === 'compact') return wholeNote(await readNote($))
55  if (source !== 'clear' && source !== 'resume' && source !== 'fork') return null
56  const shown = await openSession($, text, id)
57  return source === 'clear' ? null : arrival(shown)
58}
59
hooks/band.tsx 163 lines
1// 입력창 위 띠 — 왼쪽은 할 일(열린 항목 수, 세션의 제안), 오른쪽은 과정(지금 단계), 맨 오른쪽은 늘 있는 열기·닫기 (spec UI.md 1절)
2import type { On } from 'claude-code'
3
4import { activeProcess, currentOf } from './process'
5import { DIVIDER, LABEL, MARK, SQUARE, buttonLook, cellsOf, cut } from './text'
6import type { Text } from './text'
7import {
8  acceptProposal,
9  dismissProposal,
10  focusAfter,
11  isPaneOpen,
12  liveProposals,
13  openItems,
14  readNote,
15  togglePane,
16} from './view'
17
18// 과정 단계 제목에 남길 최소 칸(앞 띄어쓰기 포함) — 이보다 좁으면 제목을 빼고 번호만 남긴다
19const MIN_STEP_TITLE = 6
20
21// background: 설정에서 고른 띠 배경 테마 키(없으면 배경 없음). 터미널에서만 칠한다
22export function drawBand(on: On, text: Text, background?: string) {
23  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
24    if (e.props.hasSurvey) return next(e)
25    const { Box, Button, Text: T } = $.ui.resolve(e)
26    const current = await readNote($)
27    const count = openItems(current).length
28    const proposals = liveProposals(current)
29    const isEmpty = count === 0 && proposals.length === 0
30    const isOpen = await isPaneOpen($)
31
32    // 제안이 하나면 그 제목과 받기·무시 버튼, 여럿이면 개수 요약(열기가 할 일 탭으로 연다)
33    const only = proposals.length === 1 ? proposals[0] : undefined
34    const many = proposals.length > 1
35    const toggleLabel = isOpen ? text.close : text.open
36    const titleOf = (itemId: string) => current.items.find(item => item.id === itemId)?.title ?? ''
37    const fresh = proposals.filter(p => p.kind === 'new').length
38    const finished = proposals.length - fresh
39    const summary = [
40      fresh > 0 ? `${MARK.new.label} ${text.pendingNew(fresh)}` : '',
41      finished > 0 ? `${MARK.done.label} ${text.pendingDone(finished)}` : '',
42    ]
43      .filter(part => part !== '')
44      .join(' · ')
45    const proposalTitle = only === undefined ? '' : only.kind === 'new' ? only.title : titleOf(only.itemId)
46    const accept = only === undefined ? '' : buttonLook(e.surface, only.kind === 'new' ? text.accept : text.complete).label
47    const dismiss = only === undefined ? '' : buttonLook(e.surface, only.kind === 'new' ? text.dismiss : text.notYet).label
48
49    // 진행 중 과정의 지금 단계 — 없으면 과정 구역을 비운다
50    const proc = activeProcess(current)
51    const now = proc === undefined ? null : currentOf(proc)
52    const stepHead = now === null ? '' : `${LABEL.process}${DIVIDER}${SQUARE.active.label} ${now.at.number}`
53    const others = now === null || now.others === 0 ? '' : text.others(now.others)
54
55    // 좁은 띠: 글자 폭을 세어 과정 단계 제목부터 줄이고, 다음으로 제안 제목을 줄인다. 기호·개수·번호·버튼은 자르지 않는다
56    const fixed =
57      cellsOf(`${LABEL.todo} ${count}`) +
58      (only !== undefined ? cellsOf(DIVIDER) + cellsOf(MARK[only.kind].label) + 2 + 1 + cellsOf(accept) + 1 + cellsOf(dismiss) : 0) +
59      (many ? cellsOf(DIVIDER) + cellsOf(summary) : 0) +
60      (now === null ? 0 : 2 + cellsOf(stepHead) + (others === '' ? 0 : 1 + cellsOf(others))) +
61      2 +
62      cellsOf(toggleLabel)
63    const room = e.props.bodyColumns - fixed
64    const wantProposal = cellsOf(proposalTitle)
65    const wantStep = now === null ? 0 : 1 + cellsOf(now.at.step.title)
66    let proposalCells = wantProposal
67    let stepCells = wantStep
68    if (wantProposal + wantStep > room) {
69      stepCells = room - wantProposal
70      if (stepCells < MIN_STEP_TITLE) {
71        stepCells = 0
72        proposalCells = Math.max(1, room)
73      }
74    }
75    const stepTitle = now === null || stepCells === 0 ? '' : ` ${cut(now.at.step.title, stepCells - 1)}`
76
77    const mark = (kind: 'new' | 'done') => <T color={MARK[kind].color}>{MARK[kind].label}</T>
78    const pending =
79      only !== undefined ? (
80        <Box key="pending" flexDirection="row" flexShrink={1} minWidth={0}>
81          <T dimColor>{DIVIDER}</T>
82          <T wrap="truncate-end">
83            {mark(only.kind)}
84            {'  '}
85            {cut(proposalTitle, proposalCells)}
86          </T>
87          <Box flexDirection="row" gap={1} flexShrink={0} marginLeft={1}>
88            <Button
89              key={`accept-${only.id}`}
90              {...buttonLook(e.surface, only.kind === 'new' ? text.accept : text.complete)}
91              onPress={() => void focusAfter($, e.requestId, 'toggle', acceptProposal($, text, only.id))}
92            />
93            <Button
94              key={`dismiss-${only.id}`}
95              dimColor
96              {...buttonLook(e.surface, only.kind === 'new' ? text.dismiss : text.notYet)}
97              onPress={() => void focusAfter($, e.requestId, 'toggle', dismissProposal($, text, only.id))}
98            />
99          </Box>
100        </Box>
101      ) : many ? (
102        <Box key="pending" flexDirection="row" flexShrink={1} minWidth={0}>
103          <T dimColor>{DIVIDER}</T>
104          <T wrap="truncate-end">
105            {fresh > 0 ? mark('new') : ''}
106            {fresh > 0 ? <T dimColor> {text.pendingNew(fresh)}</T> : ''}
107            {fresh > 0 && finished > 0 ? <T dimColor>{' · '}</T> : ''}
108            {finished > 0 ? mark('done') : ''}
109            {finished > 0 ? <T dimColor> {text.pendingDone(finished)}</T> : ''}
110          </T>
111        </Box>
112      ) : null
113
114    // 과정 구역 — 보기만 하는 표시(버튼 없음)
115    const step =
116      now === null ? null : (
117        <Box key="process" flexShrink={0}>
118          <T wrap="truncate-end">
119            <T dimColor>
120              {LABEL.process}
121              {DIVIDER}
122            </T>
123            <T color={SQUARE.active.color}>{SQUARE.active.label}</T> {now.at.number}
124            {stepTitle}
125            {others === '' ? '' : <T dimColor> {others}</T>}
126          </T>
127        </Box>
128      )
129
130    return (
131      <Box
132        flexDirection="row"
133        justifyContent="space-between"
134        width={e.props.bodyColumns}
135        backgroundColor={e.surface === 'terminal' ? background : undefined}
136      >
137        <Box flexDirection="row" flexShrink={1} minWidth={0}>
138          <Box key="count" flexShrink={0}>
139            {isEmpty ? (
140              <T dimColor>{LABEL.todo} 0</T>
141            ) : (
142              <T>
143                <T dimColor>{LABEL.todo}</T>{' '}
144                <T bold>{String(count)}</T>
145              </T>
146            )}
147          </Box>
148          {pending}
149        </Box>
150        <Box flexDirection="row" gap={2} flexShrink={0} marginLeft={2}>
151          {step}
152          <Button
153            key="toggle"
154            plain
155            label={toggleLabel}
156            onPress={() => void togglePane($, text.notes, isOpen, many ? 'todo' : undefined)}
157          />
158        </Box>
159      </Box>
160    )
161  })
162}
163
hooks/pane.tsx 383 lines
1// 오른쪽 노트 패널 — 할 일·과정 탭. 할 일 탭은 세션의 제안·열린 항목·새 항목·완료 묶음과 펼침·수정·삭제 (spec UI.md 2·3절)
2import type { EngineInterface, On, RenderChildren } from 'claude-code'
3
4import type { Item } from './note'
5import { isFinished } from './process'
6import { drawProcessTab } from './process-pane'
7import { LABEL, MARK, buttonLook } from './text'
8import type { Text } from './text'
9import {
10  PANE,
11  acceptProposal,
12  addItem,
13  dismissProposal,
14  doneItems,
15  editFieldKey,
16  focusAfter,
17  liveProposals,
18  readNote,
19  openItems,
20  readView,
21  removeItem,
22  saveEdit,
23  setView,
24  startEdit,
25  toggleDone,
26  type Draft,
27  type Tab,
28} from './view'
29
30const SHOWN_DONE = 5
31
32// 2026-10-16T14:05:00+09:00 → 2026-10-16 14:05 (쓴 곳의 현지 시각 그대로)
33const shortTime = (iso: string) => iso.slice(0, 16).replace('T', ' ')
34
35export function drawPane(on: On, text: Text) {
36  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
37    const elements = $.ui.resolve(e)
38    const { Box, Button, Text: T } = elements
39    // 입력칸이 없는 화면(모바일)에서는 보기와 체크만 한다
40    const Input = 'Input' in elements ? elements.Input : null
41    const current = await readNote($)
42    const view = await readView($)
43    const open = openItems(current)
44    const done = doneItems(current)
45    // 구분선 — 넉넉히 그리고 한 줄로 잘라 데스크톱에서도 패널 끝까지 닿게 한다
46    const rule = (
47      <Box height={1} overflow="hidden">
48        <T dimColor>{'─'.repeat(Math.max(1, e.props.bodyColumns) * 4)}</T>
49      </Box>
50    )
51    // 누른 버튼이 사라지는 동작은 끝난 뒤 커서를 남는 버튼(key)으로 옮긴다(view.ts focusAfter)
52    const stay = (key: string, action: Promise<unknown>) => void focusAfter($, PANE, key, action)
53    const addKey = view.adding === null ? 'add' : 'add-cancel'
54    // 같은 목록에서 바로 아래(없으면 위)의 제목 버튼, 그것도 없으면 fallback
55    const nearTitle = (list: { id: string }[], id: string, prefix: string, fallback: string) => {
56      const at = list.findIndex(one => one.id === id)
57      const other = list[at + 1] ?? list[at - 1]
58      return other === undefined ? fallback : `${prefix}${other.id}`
59    }
60    const itemNear = (item: Item) =>
61      item.status === 'done'
62        ? nearTitle(shownDone, item.id, 'title-', 'done-group')
63        : nearTitle(open, item.id, 'title-', addKey)
64
65    // 새 항목·수정의 제목·내용 칸. 친 글은 그 칸 값만 상태에 쓰고, Enter는 상태의 초안 전체로 저장한다
66    const fields = (kind: 'add' | 'edit', draft: Draft) =>
67      Input === null
68        ? []
69        : (['title', 'body'] as const).map(field => (
70            <Input
71              key={kind === 'add' ? `add-${field}` : editFieldKey(field, view.editKey)}
72              placeholder={field === 'title' ? text.title : text.details}
73              value={draft[field]}
74              submitLabel={kind === 'add' ? text.submitAdd : text.submitSave}
75              onInput={(value: string) => void setDraft($, kind, field, value)}
76              onSubmit={(value: string) =>
77                stay(
78                  kind === 'add' ? 'add' : `title-${view.editing?.id}`,
79                  setDraft($, kind, field, value).then(() =>
80                    kind === 'add' ? addFromView($, text) : saveFromView($, text),
81                  ),
82                )
83              }
84            />
85          ))
86
87    const row = (item: Item) => {
88      const isDone = item.status === 'done'
89      const isOpen = view.openId === item.id
90      const editing = view.editing?.id === item.id ? view.editing : null
91      const check = (
92        <Button
93          key={`check-${item.id}`}
94          plain
95          dimColor
96          label={isDone ? '☑' : '☐'}
97          onPress={() => stay(itemNear(item), toggleDone($, text, item.id))}
98        />
99      )
100      // 체크 칸 옆 세로 묶음 — 제목·내용·입력 칸·버튼을 제목 시작 위치에 맞춘다
101      const beside = (...children: RenderChildren[]) => (
102        <Box key={`row-${item.id}`} flexDirection="row" gap={1} alignItems="flex-start">
103          {check}
104          <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
105            {children}
106          </Box>
107        </Box>
108      )
109      if (editing !== null) {
110        const [title, body] = fields('edit', editing)
111        return [
112          beside(
113            title,
114            body,
115            <Box flexDirection="row" gap={1} justifyContent="flex-end">
116              <Button
117                key="edit-ok"
118                {...buttonLook(e.surface, text.saveOk)}
119                onPress={() => stay(`title-${item.id}`, saveFromView($, text))}
120              />
121              <Button
122                key="edit-cancel"
123                dimColor
124                {...buttonLook(e.surface, text.cancel)}
125                onPress={() => stay(`title-${item.id}`, setView($, v => ({ ...v, editing: null })))}
126              />
127            </Box>,
128          ),
129        ]
130      }
131      // 제목만 누르면 펼치고 접는다
132      const head = (
133        <Box flexDirection="row">
134          <Button key={`title-${item.id}`} plain onPress={() => void toggleOpen($, item.id)}>
135            <T wrap="wrap">{item.title}</T>
136            {item.source === 'user' ? <T dimColor> ☺</T> : ''}
137          </Button>
138        </Box>
139      )
140      if (!isOpen) return [beside(head)]
141      const actions =
142        view.armedId === item.id ? (
143          <Box flexDirection="row" gap={1}>
144            <Button
145              key={`delete-ok-${item.id}`}
146              {...buttonLook(e.surface, text.delOk)}
147              onPress={() => stay(itemNear(item), removeItem($, text, item.id))}
148            />
149            <Button
150              key="delete-cancel"
151              dimColor
152              {...buttonLook(e.surface, text.cancel)}
153              onPress={() => stay(`delete-${item.id}`, setView($, v => ({ ...v, armedId: null })))}
154            />
155          </Box>
156        ) : (
157          <Box flexDirection="row" gap={1}>
158            {Input !== null && (
159              <Button
160                key={`edit-${item.id}`}
161                dimColor
162                {...buttonLook(e.surface, text.edit)}
163                onPress={() => void startEdit($, text.notes, item)}
164              />
165            )}
166            <Button
167              key={`delete-${item.id}`}
168              dimColor
169              {...buttonLook(e.surface, text.del)}
170              onPress={() => stay('delete-cancel', setView($, v => ({ ...v, armedId: item.id })))}
171            />
172          </Box>
173        )
174      return [
175        beside(
176          head,
177          <T dimColor wrap="wrap">
178            {item.body === '' ? text.noDetails : item.body}
179          </T>,
180          <Box flexDirection="row" justifyContent="space-between" alignItems="flex-start">
181            <T dimColor>{shortTime(item.createdAt)}</T>
182            {actions}
183          </Box>,
184        ),
185      ]
186    }
187
188    const adding = view.adding
189    const addRows =
190      Input === null
191        ? []
192        : adding === null
193          ? [
194              <Button
195                key="add"
196                plain
197                dimColor
198                label={text.newItem}
199                onPress={() =>
200                  stay(
201                    'add-cancel',
202                    setView($, v => ({ ...v, editing: null, armedId: null, adding: { title: '', body: '' } })),
203                  )
204                }
205              />,
206            ]
207          : [
208              ...fields('add', adding),
209              <Box flexDirection="row" gap={1} justifyContent="flex-end">
210                <Button
211                  key="add-ok"
212                  {...buttonLook(e.surface, text.addOk)}
213                  onPress={() => stay('add', addFromView($, text))}
214                />
215                <Button
216                  key="add-cancel"
217                  dimColor
218                  {...buttonLook(e.surface, text.cancel)}
219                  onPress={() => stay('add', setView($, v => ({ ...v, adding: null })))}
220                />
221              </Box>,
222            ]
223
224    const shownDone = view.showAll ? done : done.slice(0, SHOWN_DONE)
225    const doneRows =
226      done.length === 0
227        ? []
228        : [
229            <Button
230              key="done-group"
231              plain
232              dimColor
233              label={`${text.doneGroup(done.length)} ${view.showDone ? '▾' : '▸'}`}
234              onPress={() =>
235                void setView($, v => ({ ...v, showDone: !v.showDone, showAll: v.showDone ? false : v.showAll }))
236              }
237            />,
238            ...(view.showDone ? shownDone.flatMap(row) : []),
239            ...(view.showDone && !view.showAll && done.length > SHOWN_DONE
240              ? [
241                  <Box paddingLeft={2}>
242                    <Button
243                      key="more"
244                      dimColor
245                      {...buttonLook(e.surface, text.more(done.length - SHOWN_DONE))}
246                      onPress={() => stay(`title-${done[SHOWN_DONE]?.id}`, setView($, v => ({ ...v, showAll: true })))}
247                    />
248                  </Box>,
249                ]
250              : []),
251          ]
252
253    // 맨 위의 제안 — 띠와 같은 표시와 버튼으로 하나씩. 제목을 누르면 내용(완료 제안은 그 항목의 내용)을 펼친다
254    const proposals = liveProposals(current)
255    const proposalNear = (id: string) =>
256      nearTitle(proposals, id, 'ptitle-', open[0] === undefined ? addKey : `title-${open[0].id}`)
257    const proposalRows = proposals.flatMap(p => {
258      const target = p.kind === 'done' ? current.items.find(item => item.id === p.itemId) : undefined
259      const body = p.kind === 'new' ? p.body : (target?.body ?? '')
260      const detail = view.openId === p.id ? (
261        <T dimColor wrap="wrap">
262          {body === '' ? text.noDetails : body}
263        </T>
264      ) : null
265      return [
266        <Box
267          key={`proposal-${p.id}`}
268          flexDirection="row"
269          justifyContent="space-between"
270          alignItems="flex-start"
271          gap={2}
272        >
273          <Box flexDirection="row" gap={2} flexShrink={1} minWidth={0} alignItems="flex-start">
274            <Box flexShrink={0} width={3}>
275              <T color={MARK[p.kind].color}>{MARK[p.kind].label}</T>
276            </Box>
277            <Box flexDirection="column" flexShrink={1} minWidth={0}>
278              <Box flexDirection="row">
279                <Button key={`ptitle-${p.id}`} plain onPress={() => void toggleOpen($, p.id)}>
280                  <T wrap="wrap">{p.kind === 'new' ? p.title : (target?.title ?? '')}</T>
281                </Button>
282              </Box>
283              {detail}
284            </Box>
285          </Box>
286          <Box flexDirection="row" gap={1} flexShrink={0}>
287            <Button
288              key={`accept-${p.id}`}
289              {...buttonLook(e.surface, p.kind === 'new' ? text.accept : text.complete)}
290              onPress={() => stay(proposalNear(p.id), acceptProposal($, text, p.id))}
291            />
292            <Button
293              key={`dismiss-${p.id}`}
294              dimColor
295              {...buttonLook(e.surface, p.kind === 'new' ? text.dismiss : text.notYet)}
296              onPress={() => stay(proposalNear(p.id), dismissProposal($, text, p.id))}
297            />
298          </Box>
299        </Box>,
300      ]
301    })
302
303    // 맨 위 탭 줄 — 고른 탭은 밝고 굵게, 다른 탭은 회색. N은 남은 할 일 수·안 끝난 과정 수
304    const tabButton = (tab: Tab, label: string) => {
305      const chosen = view.tab === tab
306      return (
307        <Button key={`tab-${tab}`} plain onPress={() => void setView($, v => ({ ...v, tab }))}>
308          <T bold={chosen ? true : undefined} dimColor={chosen ? undefined : true}>
309            {label}
310          </T>
311        </Button>
312      )
313    }
314    // 제목은 터미널에서만 본문에 쓴다 — 데스크톱은 머리말에 제목이 있다
315    const head = (
316      <Box flexDirection="column">
317        {e.surface === 'terminal' ? <T bold>{text.notes}</T> : null}
318        <Box flexDirection="row" gap={4}>
319          {tabButton('todo', `${LABEL.todo} ${text.todo} ${open.length}`)}
320          {tabButton(
321            'process',
322            `${LABEL.process} ${text.process} ${current.processes.filter(proc => !isFinished(proc)).length}`,
323          )}
324        </Box>
325        {rule}
326      </Box>
327    )
328
329    if (view.tab === 'process')
330      return (
331        <Box flexDirection="column" width={e.props.bodyColumns} gap={1}>
332          {head}
333          {drawProcessTab($, { Box, Button, Text: T }, text, current, view, rule)}
334        </Box>
335      )
336    return (
337      <Box flexDirection="column" width={e.props.bodyColumns} gap={1}>
338        {head}
339        {proposalRows.length === 0 ? null : (
340          <Box flexDirection="column">
341            {proposalRows}
342            {rule}
343          </Box>
344        )}
345        <Box flexDirection="column">
346          {open.length === 0 ? <T dimColor>{text.none}</T> : open.flatMap(row)}
347          {rule}
348          {addRows}
349        </Box>
350        {doneRows.length === 0 ? null : <Box flexDirection="column">{doneRows}</Box>}
351      </Box>
352    )
353  })
354}
355
356// 항목·제안의 제목을 누르면 펼치고, 다시 누르거나 다른 것을 펼치면 접는다. 접을 때 그 항목의 수정 초안과 삭제 확인은 버린다
357async function toggleOpen($: EngineInterface, id: string) {
358  await setView($, v =>
359    v.openId === id
360      ? { ...v, openId: null, editing: null, armedId: null }
361      : { ...v, openId: id, editing: null, armedId: null },
362  )
363}
364
365// 초안의 한 칸만 바꾼다(그사이 다른 칸에 친 글을 덮지 않는다)
366async function setDraft($: EngineInterface, kind: 'add' | 'edit', field: keyof Draft, value: string) {
367  await setView($, v => {
368    if (kind === 'add') return v.adding === null ? v : { ...v, adding: { ...v.adding, [field]: value } }
369    return v.editing === null ? v : { ...v, editing: { ...v.editing, [field]: value } }
370  })
371}
372
373// [추가]·[저장]·칸의 Enter는 지금까지 친 초안을 쓴다
374async function addFromView($: EngineInterface, text: Text) {
375  const view = await readView($)
376  if (view.adding !== null) await addItem($, text, view.adding)
377}
378
379async function saveFromView($: EngineInterface, text: Text) {
380  const view = await readView($)
381  if (view.editing !== null) await saveEdit($, text, view.editing.id, view.editing)
382}
383
hooks/tell.ts 129 lines
1// 세션(모델)에게 노트를 전하는 글 — 사용자가 바꾼 것 한 줄, 노트 전체, 과정 보기 (spec AGENT.md 4절·6-5). 세션이 읽는 글이라 영어 하나로 쓴다
2import type { EngineInterface } from 'claude-code'
3
4import { doneItems, liveProposals, openItems } from './note'
5import type { Item, Note, Process, Proposal, Step } from './note'
6import { activeProcess, isFinished, numbered, processesInOrder, statusOf, stoppedAt } from './process'
7
8// 노트 전체를 한 번에 전하는 글의 상한 — 넘으면 내용을 빼고 제목만 보낸다
9const LIMIT = 4000
10const HEAD = '[session-note]'
11
12// 세션에게 보이는 번호는 id의 앞 8글자다
13export const refOf = (id: string) => id.slice(0, 8)
14
15const named = (item: Item) => `[${refOf(item.id)}] "${item.title}"`
16
17export const told = {
18  added: (item: Item) => `${HEAD} The user added ${named(item)} to the note.`,
19  edited: (item: Item) =>
20    `${HEAD} The user edited ${named(item)}${item.body === '' ? '' : ` — details: ${item.body}`}.`,
21  removed: (item: Item) => `${HEAD} The user deleted ${named(item)} from the note.`,
22  completed: (item: Item) => `${HEAD} The user completed ${named(item)}.`,
23  reopened: (item: Item) => `${HEAD} The user reopened ${named(item)}.`,
24  accepted: (item: Item) => `${HEAD} The user accepted your proposal; it is now item ${named(item)}.`,
25  dismissed: (title: string) => `${HEAD} The user dismissed your proposal "${title}".`,
26  confirmed: (item: Item) => `${HEAD} The user confirmed ${named(item)} is done.`,
27  notYet: (item: Item) => `${HEAD} The user said ${named(item)} is not done yet; it stays open.`,
28}
29
30const proposalLine = (note: Note, p: Proposal) => {
31  if (p.kind === 'new') return `- [${refOf(p.id)}] new item: "${p.title}"`
32  const item = note.items.find(one => one.id === p.itemId)
33  return `- [${refOf(p.id)}] done: ${item === undefined ? `[${refOf(p.itemId)}]` : named(item)}`
34}
35
36// 노트를 글로 — 열린 항목은 제목과 내용, 완료 항목은 제목, 사용자가 아직 답하지 않은 제안
37export function describe(note: Note, withBody: boolean): string {
38  const open = openItems(note)
39  const done = doneItems(note)
40  const proposals = liveProposals(note)
41  const lines = [`${HEAD} The session note now:`, 'Open items:']
42  if (open.length === 0) lines.push('- (none)')
43  for (const item of open) {
44    lines.push(`- ${named(item)}`)
45    if (withBody && item.body !== '') lines.push(`  ${item.body}`)
46  }
47  if (done.length > 0) lines.push('Done items:', ...done.map(item => `- ${named(item)}`))
48  if (proposals.length > 0)
49    lines.push('Your proposals still waiting for the user:', ...proposals.map(p => proposalLine(note, p)))
50  return lines.join('\n')
51}
52
53// 단계마다 한 줄: 깊이만큼 들여 쓰고 화면 순번·숨은 번호·제목·상태. 메모는 다음 줄
54const stepLines = (steps: Step[], withMemo: boolean) =>
55  numbered(steps).flatMap(({ step, number, depth }) => {
56    const pad = '  '.repeat(depth)
57    const line = `${pad}${number} [${refOf(step.id)}] "${step.title}" (${statusOf(step)})`
58    return withMemo && step.memo !== '' ? [line, `${pad}  memo: ${step.memo}`] : [line]
59  })
60
61// 과정 하나 — 머리줄과 단계 전체
62export const processLines = (proc: Process, withMemo: boolean) => [
63  `[${refOf(proc.id)}] "${proc.title}" (${isFinished(proc) ? 'finished' : proc.status})`,
64  ...(proc.steps.length === 0 ? ['  (no steps)'] : stepLines(proc.steps, withMemo)),
65]
66
67// 과정 도구의 보기 — 끝나지 않은 과정은 단계 전체(진행 중 과정이 맨 위), 끝난 과정은 제목만
68export function describeProcesses(note: Note): string {
69  const shown = processesInOrder(note)
70  if (shown.length === 0) return 'No processes in this session yet.'
71  const finished = shown.filter(isFinished)
72  const lines = shown.filter(proc => !isFinished(proc)).flatMap(proc => processLines(proc, true))
73  if (finished.length > 0)
74    lines.push('Finished processes:', ...finished.map(proc => `- [${refOf(proc.id)}] "${proc.title}"`))
75  return lines.join('\n')
76}
77
78const hasOpenProcess = (note: Note) => note.processes.some(proc => !isFinished(proc))
79
80// 압축·불러온 세션에 전하는 과정 — 진행 중 과정은 단계 전체, 대기 과정은 제목과 멈춘 자리
81function handedProcesses(note: Note, withMemo: boolean): string[] {
82  const active = activeProcess(note)
83  const waiting = processesInOrder(note).filter(proc => proc !== active && !isFinished(proc))
84  if (active === undefined && waiting.length === 0) return []
85  const lines = ['Your processes:']
86  if (active !== undefined) lines.push('Active process:', ...processLines(active, withMemo))
87  if (waiting.length > 0)
88    lines.push(
89      'Waiting processes:',
90      ...waiting.map(proc => {
91        const at = stoppedAt(proc)
92        const where = at === undefined ? '' : ` — stopped at ${at.number} "${at.step.title}"`
93        return `- [${refOf(proc.id)}] "${proc.title}"${where}`
94      }),
95    )
96  return lines
97}
98
99// 압축 직후에 전하는 노트 전체(할 일과 과정). 비었으면 한 줄, 상한을 넘으면 내용·메모를 빼고 제목만
100export function wholeNote(note: Note): string {
101  if (note.items.length === 0 && liveProposals(note).length === 0 && !hasOpenProcess(note))
102    return `${HEAD} This session's note is empty.`
103  const full = [describe(note, true), ...handedProcesses(note, true)].join('\n')
104  if (full.length <= LIMIT) return full
105  return [
106    describe(note, false),
107    ...handedProcesses(note, false),
108    'Details were left out; use the list action of the todo and process tools to read them.',
109  ].join('\n')
110}
111
112// /resume·--fork-session으로 불러온 세션에 전하는 글 — 열린 항목·답하지 않은 제안·끝나지 않은 과정이 있으면 전체, 완료만 있으면 한 줄, 비었으면 한 줄
113export function arrival(note: Note): string {
114  if (openItems(note).length > 0 || liveProposals(note).length > 0 || hasOpenProcess(note)) return wholeNote(note)
115  const done = doneItems(note).length
116  if (done > 0) return `${HEAD} No open items in this session's note (${done} done).`
117  return `${HEAD} This session's note is empty.`
118}
119
120// 세션에게 한 줄을 전한다. 사용자 화면에는 보이지 않고 모델만 읽는다. 거부되면 디버그 로그에만 남긴다(6-4 ⑦)
121export async function tell($: EngineInterface, text: string) {
122  try {
123    const stored = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
124    if ('deny' in stored) $.ui.log(`not told: ${String(stored.deny)}`, { to: 'debug' })
125  } catch (error) {
126    $.ui.log(`not told: ${String(error)}`, { to: 'debug' })
127  }
128}
129
hooks/text.ts 142 lines
1// 띠·패널의 화면 문구 표 — 설치 때 고른 화면 언어(English·한국어)로 고른다 (spec UI.md 5절)
2
3export type Lang = 'en' | 'ko'
4
5const TEXT = {
6  en: {
7    notes: 'Notes',
8    todo: 'To do',
9    process: 'Process',
10    others: (n: number) => `+${n} more`,
11    pausedAt: (number: string) => `paused at ${number}`,
12    finished: (n: number) => `Finished ${n}`,
13    noProcesses: 'No open processes',
14    open: 'Open',
15    close: 'Close',
16    newItem: '+ New item',
17    title: 'Title',
18    details: 'Details',
19    addOk: 'Add',
20    saveOk: 'Save',
21    cancel: 'Cancel',
22    edit: 'Edit',
23    del: 'Delete',
24    delOk: 'Confirm delete',
25    none: 'No open items',
26    noDetails: '(No details)',
27    doneGroup: (n: number) => `Done ${n}`,
28    more: (n: number) => `Show more (${n})`,
29    submitAdd: 'Add',
30    submitSave: 'Save',
31    accept: 'Add',
32    dismiss: 'Dismiss',
33    complete: 'Complete',
34    notYet: 'Not yet',
35    pendingNew: (n: number) => `${n} new`,
36    pendingDone: (n: number) => `${n} done`,
37    command: 'Open the notes pane',
38    saveFailed: "Couldn't save the note",
39    unreadable: "Couldn't read the saved note; kept a copy",
40  },
41  ko: {
42    notes: '노트',
43    todo: '할 일',
44    process: '과정',
45    others: (n: number) => `외 ${n}`,
46    pausedAt: (number: string) => `${number}에서 멈춤`,
47    finished: (n: number) => `끝난 과정 ${n}`,
48    noProcesses: '남은 과정이 없어요',
49    open: '열기',
50    close: '닫기',
51    newItem: '+ 새 항목',
52    title: '제목',
53    details: '내용',
54    addOk: '추가',
55    saveOk: '저장',
56    cancel: '취소',
57    edit: '수정',
58    del: '삭제',
59    delOk: '삭제 확인',
60    none: '남은 항목이 없어요',
61    noDetails: '(내용 없음)',
62    doneGroup: (n: number) => `완료 ${n}`,
63    more: (n: number) => `더 보기 (${n})`,
64    submitAdd: '추가',
65    submitSave: '저장',
66    accept: '추가',
67    dismiss: '무시',
68    complete: '완료',
69    notYet: '아직',
70    pendingNew: (n: number) => `새 항목 ${n}`,
71    pendingDone: (n: number) => `끝난 항목 ${n}`,
72    command: '노트 패널을 열어요',
73    saveFailed: '노트를 저장하지 못했어요',
74    unreadable: '저장된 노트를 읽지 못해 사본을 남겼어요',
75  },
76}
77
78export type Text = (typeof TEXT)['en']
79
80// 제안 종류 표시 — 언어와 상관없이 새 항목 제안은 파란 [+], 완료 제안은 초록 [✓] (테마 키)
81export const MARK = {
82  new: { label: '[+]', color: 'suggestion' },
83  done: { label: '[✓]', color: 'success' },
84} as const
85
86// 띠·탭의 이름표 기호 — 할 일 ☑, 과정 ≡ (이름표와 같은 회색)
87export const LABEL = { todo: '☑', process: '≡' } as const
88
89// 띠의 흐린 구분선 — 할 일 개수와 제안 사이, 과정 기호와 지금 단계 사이
90export const DIVIDER = ' │ '
91
92// 단계·과정의 상태 네모 — 대기 ◻, 진행 중·완료·중단 ◼에 파랑·초록·빨강 (테마 키)
93export const SQUARE = {
94  waiting: { label: '◻', color: undefined },
95  active: { label: '◼', color: 'suggestion' },
96  done: { label: '◼', color: 'success' },
97  stopped: { label: '◼', color: 'error' },
98} as const
99
100// 글자의 터미널 칸 수 — 한글·한자·전각·그림 글자는 두 칸, 그 밖은 한 칸
101const isWide = (code: number) =>
102  (code >= 0x1100 && code <= 0x115f) ||
103  (code >= 0x2e80 && code <= 0xa4cf) ||
104  (code >= 0xac00 && code <= 0xd7a3) ||
105  (code >= 0xf900 && code <= 0xfaff) ||
106  (code >= 0xfe30 && code <= 0xfe4f) ||
107  (code >= 0xff00 && code <= 0xff60) ||
108  (code >= 0xffe0 && code <= 0xffe6) ||
109  (code >= 0x1f300 && code <= 0x1faff) ||
110  (code >= 0x20000 && code <= 0x3fffd)
111
112export const cellsOf = (line: string) => [...line].reduce((n, ch) => n + (isWide(ch.codePointAt(0) ?? 0) ? 2 : 1), 0)
113
114// cells 칸 안에 들어가게 끝을 …로 줄인다
115export function cut(line: string, cells: number): string {
116  if (cellsOf(line) <= cells) return line
117  let kept = ''
118  for (const ch of line) {
119    if (cellsOf(kept + ch) > cells - 1) break
120    kept += ch
121  }
122  return `${kept.trimEnd()}…`
123}
124
125// 동작 버튼 — 두 화면 모두 글자 버튼이고, 터미널만 [이름]으로 감싼다
126export function buttonLook(surface: string, label: string) {
127  return { plain: true as const, label: surface === 'terminal' ? `[${label}]` : label }
128}
129
130// userConfig language 값 → 문구. 모르는 값은 기본 English
131export const langOf = (value: unknown): Lang => (value === '한국어' ? 'ko' : 'en')
132
133export const textOf = (lang: Lang): Text => TEXT[lang]
134
135// userConfig bandBackground 값 → 띠 배경 테마 키. None·모르는 값은 배경 없음 (spec UI.md 1절)
136const BACKGROUND = { Gray: 'subtle', Olive: 'diffAddedDimmed', Wine: 'diffRemovedDimmed' } as const
137
138export const backgroundOf = (value: unknown) =>
139  typeof value === 'string' && Object.hasOwn(BACKGROUND, value)
140    ? BACKGROUND[value as keyof typeof BACKGROUND]
141    : undefined
142
hooks/process-tool.ts 340 lines
1// 세션(모델)이 직접 짜고 고치는 과정 도구(process) — 보기·짜기·단계 넣기·고치기·빼기·옮기기·상태 바꾸기·진행 중 과정 바꾸기·제목 고치기·지우기 (spec AGENT.md 6절)
2// 도구는 세션의 호출을 행동 이유로 거절하지 않는다. 거절은 데이터 모양(깊이·상태 값·없는 번호)에만 한다
3import type { EngineInterface, On } from 'claude-code'
4
5import { isoAt, newId } from './note'
6import type { Note, Process, Step, StepStatus } from './note'
7import {
8  MAX_DEPTH,
9  STEP_STATUSES,
10  activate,
11  activeProcess,
12  editStep,
13  findProcess,
14  findStep,
15  heightOf,
16  insertSteps,
17  leavesOf,
18  moveStep,
19  removeSteps,
20  setStatus,
21  settle,
22  statusOf,
23  withProcess,
24} from './process'
25import { describeProcesses, processLines, refOf } from './tell'
26import { readNote, saveChange } from './view'
27
28const NAME = 'process'
29const CALLED = 'mcp__session-note__process'
30
31// 행동은 권고로만 준다(내장 작업 목록처럼) — 할 일 도구 설명보다 확실히 짧게(spec AGENT.md 6-4)
32const DESCRIPTION = `Your process: a roadmap you write and keep current for multi-step work with the user. The user sees the current step above the prompt and the whole process in the side pane, and changes it only by asking you. Steps and processes have an 8-character ref like [a1b2c3d4]; a step can also be named by its on-screen number ("3", "2.1"), in the active process unless you give one.
33
34When: use it proactively, without being asked, for work that will span several turns or steps the user may later ask about ("where are we?"); skip one-reply tasks. Make one if the user asks; not if they say no. Plan the top level at the start; fill sub-steps as you enter a step. Two levels by default, a third only when needed.
35
36Status: set a step active before you start it and done right after. stopped = not finished or dropped. Postponed for another process is not stopped. If the user says remove a step, remove it.
37
38Say one line when you create a process, or change its top level on your own (with why). Don't mention starting, finishing or filling steps, or changes the user asked for.
39
40Interruptions: a quick side task stays out (todo); a prerequisite goes in as a sub-step where you are; a big task with another goal is a new process (the old one waits); a changed goal: show the new plan, get an OK, create it, stop the old unfinished steps. Say any change in a very short line.
41
42Titles: short noun phrases (~15 chars), not repeating the parent, not one-word tags. If the built-in task list is on, plan here only.`
43
44const STEP_INPUT = {
45  type: 'object',
46  properties: {
47    title: { type: 'string' },
48    memo: { type: 'string', description: 'Notes for you; not shown to the user.' },
49    status: { type: 'string', enum: STEP_STATUSES },
50    steps: { type: 'array', items: { type: 'object' }, description: 'Sub-steps, same shape.' },
51  },
52  required: ['title'],
53} as const
54
55const SCHEMA = {
56  type: 'object',
57  properties: {
58    action: {
59      type: 'string',
60      enum: ['list', 'create', 'add', 'edit', 'remove', 'move', 'set', 'switch', 'rename', 'delete'],
61      description:
62        'list; create (title, steps, active=false to keep it waiting); add (steps at parent/before/after); edit (id, title, memo); remove (ids); move (id to parent/before/after); set (changes); switch (make process active); rename (process, title); delete (process).',
63    },
64    process: { type: 'string', description: 'Process ref; default the active process.' },
65    title: { type: 'string' },
66    memo: { type: 'string' },
67    steps: { type: 'array', items: STEP_INPUT },
68    id: { type: 'string', description: 'Step ref or number.' },
69    ids: { type: 'array', items: { type: 'string' } },
70    parent: { type: 'string', description: 'Put under this step; omit for the top level.' },
71    before: { type: 'string' },
72    after: { type: 'string' },
73    changes: {
74      type: 'array',
75      items: {
76        type: 'object',
77        properties: { id: { type: 'string' }, status: { type: 'string', enum: STEP_STATUSES } },
78        required: ['id', 'status'],
79      },
80    },
81    active: { type: 'boolean' },
82  },
83  required: ['action'],
84} as const
85
86type Answer = { result: string } | { deny: string }
87
88const textOf = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
89
90// [a1b2c3d4]·a1b2c3d4·id 전체·순번 모두 받는다
91const refIn = (value: unknown) => textOf(value).replace(/^\[|\]$/g, '')
92
93const unsaved: Answer = { deny: "Couldn't save the note (or it is not open yet). Try again later." }
94
95const named = (step: Step) => `[${refOf(step.id)}] "${step.title}"`
96
97export function registerProcessTool($: EngineInterface) {
98  return $.tool.register({ name: NAME, description: DESCRIPTION, inputSchema: SCHEMA, isDeferred: false })
99}
100
101export function serveProcessTool(on: On) {
102  on('tool.call', { tool: CALLED }, async ($, e) => answer($, e as unknown as Record<string, unknown>)).catch(() => ({
103    deny: 'The process tool failed. Try again later.',
104  }))
105}
106
107async function answer($: EngineInterface, args: Record<string, unknown>): Promise<Answer> {
108  const action = textOf(args['action'])
109  const note = await readNote($)
110  if (action === 'list') return { result: describeProcesses(note) }
111  if (action === 'create') return create($, args)
112  const procRef = refIn(args['process'])
113  const proc = procRef === '' ? activeProcess(note) : findProcess(note, procRef)
114  if (proc === undefined)
115    return {
116      deny:
117        procRef === ''
118          ? 'There is no active process. Give a process ref, or create one.'
119          : `No process [${procRef}]. Use list to see the refs.`,
120    }
121  if (action === 'switch') return save($, activate, proc, `"${proc.title}" is now the active process.`)
122  if (action === 'rename') {
123    const title = textOf(args['title'])
124    if (title === '') return { deny: 'Give the new process title.' }
125    return change($, proc, one => ({ ...one, title }), `Renamed the process to "${title}".`)
126  }
127  if (action === 'delete') {
128    const drop = (n: Note, id: string) => ({ ...n, processes: n.processes.filter(one => one.id !== id) })
129    return save($, drop, proc, `Deleted "${proc.title}".`)
130  }
131  if (action === 'add') return add($, proc, args)
132  if (action === 'edit') return edit($, proc, args)
133  if (action === 'remove') return remove($, proc, args)
134  if (action === 'move') return move($, proc, args)
135  if (action === 'set') return set($, proc, args)
136  return {
137    deny: `Unknown action "${action}". Use list, create, add, edit, remove, move, set, switch, rename or delete.`,
138  }
139}
140
141// 노트를 바꿔 저장하고 한 줄로 답한다
142async function save($: EngineInterface, edit: (n: Note, id: string) => Note, proc: Process, line: string) {
143  const saved = await saveChange($, n => edit(n, proc.id))
144  return saved === null ? unsaved : { result: line }
145}
146
147// 과정 하나를 바꿔 저장한다. 답은 한 줄, full이면 바뀐 과정 전체를 붙인다
148async function change(
149  $: EngineInterface,
150  proc: Process,
151  edit: (proc: Process) => Process,
152  line: string,
153  full = false,
154): Promise<Answer> {
155  const saved = await saveChange($, n => withProcess(n, proc.id, edit))
156  if (saved === null) return unsaved
157  const now = saved.processes.find(one => one.id === proc.id)
158  if (!full || now === undefined) return { result: line }
159  return { result: [line, ...processLines(now, false)].join('\n') }
160}
161
162// 입력 단계 → 새 단계(새 숨은 번호). 제목이 없거나 상태 값이 틀리면 이유를 돌려준다
163function stepsIn(value: unknown): Step[] | string {
164  if (value === undefined) return []
165  if (!Array.isArray(value)) return 'steps must be a list.'
166  const steps: Step[] = []
167  for (const raw of value) {
168    const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
169    const title = textOf(input['title'])
170    if (title === '') return 'Every step needs a title.'
171    const status = input['status'] === undefined ? 'waiting' : textOf(input['status'])
172    if (!STEP_STATUSES.includes(status as StepStatus))
173      return `Unknown status "${status}". Use waiting, active, done or stopped.`
174    const below = stepsIn(input['steps'])
175    if (typeof below === 'string') return below
176    steps.push({ id: newId(), title, memo: textOf(input['memo']), status: status as StepStatus, steps: below })
177  }
178  return steps
179}
180
181const tooDeep = `Steps go at most ${MAX_DEPTH} levels deep.`
182
183async function create($: EngineInterface, args: Record<string, unknown>): Promise<Answer> {
184  const title = textOf(args['title'])
185  if (title === '') return { deny: 'A process needs a title.' }
186  const steps = stepsIn(args['steps'])
187  if (typeof steps === 'string') return { deny: steps }
188  if (steps.some(step => heightOf(step) > MAX_DEPTH)) return { deny: tooDeep }
189  const isActive = args['active'] !== false
190  const proc: Process = {
191    id: newId(),
192    title,
193    status: 'waiting',
194    createdAt: isoAt(await $.clock.now()),
195    steps: settle(steps),
196  }
197  const saved = await saveChange($, n => {
198    const added = { ...n, processes: [...n.processes, proc] }
199    return isActive ? activate(added, proc.id) : added
200  })
201  if (saved === null) return unsaved
202  const made = saved.processes.find(one => one.id === proc.id) ?? proc
203  return { result: ['Created:', ...processLines(made, false)].join('\n') }
204}
205
206// 넣을 자리 — before·after면 그 단계의 형제 자리, parent면 그 아래 끝, 아무것도 없으면 1단 끝
207type Place = { parentId: string | null; index: number; depth: number }
208
209function placeIn(proc: Process, args: Record<string, unknown>): Place | string {
210  for (const side of ['before', 'after'] as const) {
211    const ref = refIn(args[side])
212    if (ref === '') continue
213    const near = findStep(proc, ref)
214    if (near === undefined) return `No step ${ref}. Use list to see the steps.`
215    const siblings = near.parentId === null ? proc.steps : (findStep(proc, near.parentId)?.step.steps ?? [])
216    const at = siblings.findIndex(one => one.id === near.step.id)
217    return { parentId: near.parentId, index: side === 'before' ? at : at + 1, depth: near.depth }
218  }
219  const ref = refIn(args['parent'])
220  if (ref === '') return { parentId: null, index: proc.steps.length, depth: 1 }
221  const parent = findStep(proc, ref)
222  if (parent === undefined) return `No step ${ref}. Use list to see the steps.`
223  return { parentId: parent.step.id, index: parent.step.steps.length, depth: parent.depth + 1 }
224}
225
226async function add($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
227  const steps = stepsIn(args['steps'])
228  if (typeof steps === 'string') return { deny: steps }
229  if (steps.length === 0) return { deny: 'Give the steps to add.' }
230  const place = placeIn(proc, args)
231  if (typeof place === 'string') return { deny: place }
232  if (steps.some(step => place.depth + heightOf(step) - 1 > MAX_DEPTH)) return { deny: tooDeep }
233  return change(
234    $,
235    proc,
236    one => insertSteps(one, place.parentId, place.index, steps),
237    `Added ${steps.map(named).join(', ')}. Now:`,
238    true,
239  )
240}
241
242async function edit($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
243  const ref = refIn(args['id'])
244  const found = findStep(proc, ref)
245  if (found === undefined) return { deny: `No step ${ref}. Use list to see the steps.` }
246  const title = textOf(args['title'])
247  const memo = typeof args['memo'] === 'string' ? args['memo'].trim() : undefined
248  if (title === '' && memo === undefined) return { deny: 'Give a new title or memo to edit.' }
249  const line = `Edited ${found.number} ${named({ ...found.step, title: title || found.step.title })}.`
250  return change($, proc, one => editStep(one, found.step.id, title, memo), line)
251}
252
253async function remove($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
254  const refs = [...(Array.isArray(args['ids']) ? args['ids'] : []), args['id']].map(refIn).filter(ref => ref !== '')
255  if (refs.length === 0) return { deny: 'Give the ids of the steps to remove.' }
256  const found = refs.map(ref => findStep(proc, ref))
257  const missing = refs.filter((_, n) => found[n] === undefined)
258  if (missing.length > 0) return { deny: `No step ${missing.join(', ')}. Use list to see the steps.` }
259  const steps = found.flatMap(one => (one === undefined ? [] : [one.step]))
260  return change(
261    $,
262    proc,
263    one => removeSteps(one, new Set(steps.map(step => step.id))),
264    `Removed ${steps.map(named).join(', ')}. Now:`,
265    true,
266  )
267}
268
269async function move($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
270  const ref = refIn(args['id'])
271  const found = findStep(proc, ref)
272  if (found === undefined) return { deny: `No step ${ref}. Use list to see the steps.` }
273  // 자리 순번은 옮기기 전 기준으로 숨은 번호로 바꾼 뒤, 옮길 단계를 뺀 과정에서 자리를 찾는다
274  const near: Record<string, string> = {}
275  for (const side of ['parent', 'before', 'after'] as const) {
276    const sideRef = refIn(args[side])
277    if (sideRef === '') continue
278    const target = findStep(proc, sideRef)
279    if (target === undefined) return { deny: `No step ${sideRef}. Use list to see the steps.` }
280    if (target.path.slice(0, found.depth).join('.') === found.number)
281      return { deny: 'A step cannot move next to or under itself.' }
282    near[side] = target.step.id
283  }
284  const place = placeIn(removeSteps(proc, new Set([found.step.id])), near)
285  if (typeof place === 'string') return { deny: place }
286  if (place.depth + heightOf(found.step) - 1 > MAX_DEPTH) return { deny: tooDeep }
287  return change(
288    $,
289    proc,
290    one => moveStep(one, found.step.id, place.parentId, place.index),
291    `Moved ${named(found.step)}. Now:`,
292    true,
293  )
294}
295
296// 답은 저장된 과정에서 만든다 — 세부 단계가 있는 단계는 세부 단계를 따르므로 요청한 상태가 되지 않을 수 있다
297async function set($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
298  const changes = Array.isArray(args['changes']) ? args['changes'] : []
299  if (changes.length === 0) return { deny: 'Give changes: a list of { id, status }.' }
300  const resolved: { id: string; status: StepStatus; before: Step }[] = []
301  // 한 호출 안의 순번은 모두 호출 전 기준으로 찾는다
302  for (const raw of changes) {
303    const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
304    const ref = refIn(input['id'])
305    const status = textOf(input['status'])
306    const found = findStep(proc, ref)
307    if (found === undefined) return { deny: `No step ${ref}. Use list to see the steps.` }
308    if (!STEP_STATUSES.includes(status as StepStatus))
309      return { deny: `Unknown status "${status}". Use waiting, active, done or stopped.` }
310    resolved.push({ id: found.step.id, status: status as StepStatus, before: found.step })
311  }
312  const saved = await saveChange($, n =>
313    withProcess(n, proc.id, one => resolved.reduce((acc, next) => setStatus(acc, next.id, next.status), one)),
314  )
315  if (saved === null) return unsaved
316  const now = saved.processes.find(one => one.id === proc.id) ?? proc
317  const leafStatuses = (step: Step) => leavesOf(step).map(leaf => leaf.status).join()
318  const done: string[] = []
319  const missed: string[] = []
320  for (const { id, status, before } of resolved) {
321    const after = findStep(now, id)
322    if (after === undefined) continue
323    const actual = statusOf(after.step)
324    if (actual === status) {
325      done.push(`${after.number} ${actual}`)
326      continue
327    }
328    const below = after.step.steps.map((child, n) => `${after.number}.${n + 1} ${statusOf(child)}`).join(', ')
329    const why = below === '' ? '' : ` (a step with sub-steps follows them: ${below})`
330    missed.push(
331      leafStatuses(before) === leafStatuses(after.step)
332        ? `${after.number} unchanged: still ${actual}, not ${status}${why}.`
333        : `${after.number} is ${actual}, not ${status}${why}.`,
334    )
335  }
336  const waiting = proc.status === 'waiting' ? `"${proc.title}" is waiting; use switch to make it active.` : ''
337  const head = done.length === 0 ? [] : [`Set ${done.join(', ')}.`]
338  return { result: [...head, ...missed, waiting].filter(line => line !== '').join(' ') }
339}
340
hooks/todo-tool.ts 200 lines
1// 세션(모델)이 쓰는 할 일 도구(todo) — 목록·제안·완료 제안, 사용자가 시킨 추가·완료·수정·삭제·되돌리기, 대화로 받은 제안 (spec AGENT.md 1~3절)
2import type { EngineInterface, On } from 'claude-code'
3
4import { isoAt, newId, openItems } from './note'
5import type { Item, Note, Proposal } from './note'
6import { describe, refOf } from './tell'
7import {
8  acceptIn,
9  completeIn,
10  editIn,
11  forgetItem,
12  liveProposals,
13  readNote,
14  removeIn,
15  reopenIn,
16  saveChange,
17} from './view'
18
19const NAME = 'todo'
20const CALLED = 'mcp__session-note__todo'
21
22// 제안 기준과 완료 대조 규칙은 이 설명문에 고정한다 — 매 답변 노트를 넣지 않는다(성공 기준 ③)
23const DESCRIPTION = `The session note: a short list of things to do or check in this session, shared with the user. The user sees it above the prompt and in a side pane, and can add, edit, complete or delete items there; those changes reach you as messages starting with [session-note]. Items and proposals are named by an 8-character ref such as [a1b2c3d4].
24
25Actions:
26- list: the whole note (open items with details, done items, your proposals still waiting).
27- propose: suggest a new item (title, optional body). It waits on the user's band until they add or dismiss it.
28- propose_done: suggest that an open item is done (id). The user completes it or says not yet.
29- add: add an item (title, optional body) — only when the user tells you to note something.
30- complete: complete an item (id) — only when the user tells you it is done.
31- edit: change an item's title and/or body (id, title, body; an empty body clears it) — only when the user tells you to.
32- delete: delete an item (id) — only when the user tells you to.
33- reopen: reopen a done item (id) — only when the user tells you it is not done.
34- accept: accept a waiting proposal (id) — when the user answers it in the conversation ("keep it").
35
36Never edit, delete or reopen items on your own. To change an item, edit it; do not add a new item in its place.
37
38When to propose: only what the conversation clearly put off for later ("later", "let's skip that for now") and questions left without a conclusion ("is this right?", "we should check that"). Do not propose work you are about to do now, details you may decide yourself, or steps of your own plan. Never ask in your reply whether to add something to the note, and do not point the user to your proposals; they see them on the band. Propose and move on.
39
40Completion check: when you finish a piece of work, and when the user asks you to check the note, compare the open items with what was done. For an item that looks done, use propose_done; never complete it yourself unless the user tells you to. After finishing a piece of work, if open items remain, mention them briefly at the end of your reply. Do not check the note on every turn.`
41
42const SCHEMA = {
43  type: 'object',
44  properties: {
45    action: {
46      type: 'string',
47      enum: ['list', 'propose', 'propose_done', 'add', 'complete', 'edit', 'delete', 'reopen', 'accept'],
48    },
49    title: { type: 'string', description: 'The item title, one short line (propose, add, edit).' },
50    body: { type: 'string', description: 'Optional details (propose, add, edit).' },
51    id: {
52      type: 'string',
53      description: 'The 8-character ref of an item or proposal (propose_done, complete, edit, delete, reopen, accept).',
54    },
55  },
56  required: ['action'],
57} as const
58
59type Answer = { result: string } | { deny: string }
60
61const textOf = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
62
63// [a1b2c3d4]·a1b2c3d4·id 전체 모두 받는다
64const refIn = (value: unknown) => textOf(value).replace(/^\[|\]$/g, '')
65
66const sameRef = (id: string, ref: string) => ref !== '' && (id === ref || refOf(id) === ref)
67
68const named = (item: Item) => `[${refOf(item.id)}] "${item.title}"`
69
70const unsaved: Answer = { deny: "Couldn't save the note (or it is not open yet). Try again later." }
71
72export function registerTool($: EngineInterface) {
73  return $.tool.register({ name: NAME, description: DESCRIPTION, inputSchema: SCHEMA, isDeferred: false })
74}
75
76export function serveTool(on: On) {
77  on('tool.call', { tool: CALLED }, async ($, e) => answer($, e as unknown as Record<string, unknown>)).catch(() => ({
78    deny: 'The todo tool failed. Try again later.',
79  }))
80}
81
82async function answer($: EngineInterface, args: Record<string, unknown>): Promise<Answer> {
83  const action = textOf(args['action'])
84  if (action === 'list') return { result: describe(await readNote($), true) }
85  if (action === 'propose') return propose($, textOf(args['title']), textOf(args['body']))
86  if (action === 'add') return add($, textOf(args['title']), textOf(args['body']))
87  const ref = refIn(args['id'])
88  if (action === 'propose_done') return proposeDone($, ref)
89  if (action === 'complete') return complete($, ref)
90  if (action === 'edit') return edit($, ref, textOf(args['title']), bodyIn(args['body']))
91  if (action === 'delete') return remove($, ref)
92  if (action === 'reopen') return reopen($, ref)
93  if (action === 'accept') return accept($, ref)
94  return {
95    deny: `Unknown action "${action}". Use list, propose, propose_done, add, complete, edit, delete, reopen or accept.`,
96  }
97}
98
99const openItem = (n: Note, ref: string) => openItems(n).find(item => sameRef(item.id, ref))
100
101const anyItem = (n: Note, ref: string) => n.items.find(item => sameRef(item.id, ref))
102
103// edit의 내용 — 주지 않았으면 그대로 두고, 빈 글이면 비운다
104const bodyIn = (value: unknown) => (typeof value === 'string' ? value.trim() : undefined)
105
106async function propose($: EngineInterface, title: string, body: string): Promise<Answer> {
107  if (title === '') return { deny: 'A proposal needs a title.' }
108  const current = await readNote($)
109  const same = (other: string) => other.toLowerCase() === title.toLowerCase()
110  if (openItems(current).some(item => same(item.title))) return { result: `"${title}" is already an open item.` }
111  if (current.proposals.some(p => p.kind === 'new' && same(p.title)))
112    return { result: `"${title}" is already proposed and waiting for the user.` }
113  const proposal: Proposal = { id: newId(), kind: 'new', title, body, proposedAt: isoAt(await $.clock.now()) }
114  const saved = await saveChange($, n => ({ ...n, proposals: [...n.proposals, proposal] }))
115  if (saved === null) return unsaved
116  return {
117    result: `Proposed "${title}" [${refOf(proposal.id)}]. It waits on the user's band; do not mention it in your reply.`,
118  }
119}
120
121async function proposeDone($: EngineInterface, ref: string): Promise<Answer> {
122  const item = openItem(await readNote($), ref)
123  if (item === undefined) return { deny: `No open item [${ref}]. Use list to see the refs.` }
124  if ((await readNote($)).proposals.some(p => p.kind === 'done' && p.itemId === item.id))
125    return { result: `${named(item)} is already proposed as done and waiting for the user.` }
126  const proposal: Proposal = { id: newId(), kind: 'done', itemId: item.id, proposedAt: isoAt(await $.clock.now()) }
127  const saved = await saveChange($, n => ({ ...n, proposals: [...n.proposals, proposal] }))
128  if (saved === null) return unsaved
129  return { result: `Proposed ${named(item)} as done. It waits on the user's band; do not mention it in your reply.` }
130}
131
132async function add($: EngineInterface, title: string, body: string): Promise<Answer> {
133  if (title === '') return { deny: 'An item needs a title.' }
134  const item: Item = {
135    id: newId(),
136    title,
137    body,
138    source: 'instruction',
139    status: 'open',
140    createdAt: isoAt(await $.clock.now()),
141    doneAt: null,
142  }
143  const saved = await saveChange($, n => ({ ...n, items: [...n.items, item] }))
144  if (saved === null) return unsaved
145  return { result: `Added ${named(item)}.` }
146}
147
148async function complete($: EngineInterface, ref: string): Promise<Answer> {
149  const item = openItem(await readNote($), ref)
150  if (item === undefined) return { deny: `No open item [${ref}]. Use list to see the refs.` }
151  const at = isoAt(await $.clock.now())
152  const saved = await saveChange($, n => completeIn(n, item.id, at))
153  if (saved === null) return unsaved
154  return { result: `Completed ${named(item)}.` }
155}
156
157async function edit($: EngineInterface, ref: string, title: string, body: string | undefined): Promise<Answer> {
158  const item = anyItem(await readNote($), ref)
159  if (item === undefined) return { deny: `No item [${ref}]. Use list to see the refs.` }
160  if (title === '' && body === undefined) return { deny: 'Give a new title or body to edit.' }
161  const saved = await saveChange($, n => editIn(n, item.id, title, body))
162  if (saved === null) return unsaved
163  const edited = saved.items.find(one => one.id === item.id)
164  if (edited === undefined) return { deny: `The item [${ref}] is gone.` }
165  return { result: `Edited ${named(edited)}.` }
166}
167
168async function remove($: EngineInterface, ref: string): Promise<Answer> {
169  const item = anyItem(await readNote($), ref)
170  if (item === undefined) return { deny: `No item [${ref}]. Use list to see the refs.` }
171  const saved = await saveChange($, n => removeIn(n, item.id))
172  if (saved === null) return unsaved
173  await forgetItem($, item.id)
174  return { result: `Deleted ${named(item)}.` }
175}
176
177async function reopen($: EngineInterface, ref: string): Promise<Answer> {
178  const item = anyItem(await readNote($), ref)
179  if (item === undefined) return { deny: `No item [${ref}]. Use list to see the refs.` }
180  if (item.status === 'open') return { result: `${named(item)} is already open.` }
181  const saved = await saveChange($, n => reopenIn(n, item.id))
182  if (saved === null) return unsaved
183  return { result: `Reopened ${named(item)}.` }
184}
185
186async function accept($: EngineInterface, ref: string): Promise<Answer> {
187  const proposal = liveProposals(await readNote($)).find(p => sameRef(p.id, ref))
188  if (proposal === undefined) return { deny: `No waiting proposal [${ref}]. Use list to see the refs.` }
189  const at = isoAt(await $.clock.now())
190  const itemId = newId()
191  const saved = await saveChange($, n => {
192    const latest = n.proposals.find(p => p.id === proposal.id)
193    return latest === undefined ? n : acceptIn(n, latest, at, itemId)
194  })
195  if (saved === null) return unsaved
196  const item = saved.items.find(one => one.id === (proposal.kind === 'new' ? itemId : proposal.itemId))
197  if (item === undefined) return { deny: `The proposal [${ref}] is gone.` }
198  return { result: proposal.kind === 'new' ? `Added ${named(item)}.` : `Completed ${named(item)}.` }
199}
200
hooks/view.ts 287 lines
1// 띠·패널이 그리는 값($.state)과 노트를 바꾸는 동작 — 바꿀 때마다 파일에 저장하고, 실패하면 화면을 그대로 둔다
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface } from 'claude-code'
4
5import type { Draft, Item, Note, Proposal, Tab, View } from '../types'
6import { emptyNote, isoAt, newId } from './note'
7
8export { doneItems, liveProposals, openItems } from './note'
9import { keepCopy, loadNote, notePath, saveNote } from './store'
10import { tell, told } from './tell'
11import type { Text } from './text'
12
13export type { Draft, Tab, View } from '../types'
14
15export const PANE = 'session-note'
16
17// 모듈을 불러올 때마다 새로 정해진다 — 다시 켜지기 전의 초안을 알아보는 표시
18export const LOAD = newId()
19
20const blankView = (): View => ({
21  load: LOAD,
22  openId: null,
23  armedId: null,
24  showDone: false,
25  showAll: false,
26  adding: null,
27  editing: null,
28  editKey: LOAD,
29  tab: 'todo',
30  folds: {},
31  showFinished: false,
32})
33
34const session = atom({ plugin: 'session-note', key: 'session' } as const, null)
35const note = atom({ plugin: 'session-note', key: 'note' } as const, emptyNote())
36const paneOpen = atom({ plugin: 'session-note', key: 'paneOpen' } as const, false)
37const viewAtom = atom({ plugin: 'session-note', key: 'view' } as const, blankView())
38
39// 엔진은 다른 파일로 넘긴 $를 function 선언 안에서만 따라가므로, $를 받는 공용 함수는 모두 function으로 쓴다
40export async function readView($: EngineInterface): Promise<View> {
41  const view = await read($, viewAtom)
42  return view.load === LOAD ? view : blankView()
43}
44
45export async function setView($: EngineInterface, change: (view: View) => View) {
46  await update($, viewAtom, view => change(view.load === LOAD ? view : blankView()))
47}
48
49// 엔진은 상태 값을 그 값을 정의한 파일 안에서만 읽고 쓰게 하므로, 다른 파일은 아래 함수로 다룬다
50export async function readNote($: EngineInterface): Promise<Note> {
51  return read($, note)
52}
53
54export async function isPaneOpen($: EngineInterface): Promise<boolean> {
55  return read($, paneOpen)
56}
57
58// 세션의 노트를 화면 값으로 올리고, 패널이 지금 열려 있는지 맞춘다. 앞 세션의 초안·펼침은 버린다
59async function showSession($: EngineInterface, opened: { id: string; path: string; overwrite: boolean }, shown: Note) {
60  await update($, session, () => opened)
61  await update($, note, () => shown)
62  await update($, viewAtom, () => blankView())
63  const panes = await $.ui.panes()
64  await update($, paneOpen, () => panes.some(pane => pane.id === PANE))
65}
66
67// 세션 시작·전환 때 그 세션의 노트 파일을 연다. 읽을 수 없으면 사본을 남기고 빈 노트로 시작한다(spec DATA.md 3절)
68export async function openSession($: EngineInterface, text: Text, id: string): Promise<Note> {
69  const path = await notePath($, id)
70  const loaded = await loadNote($, path)
71  if (loaded.ok) {
72    await showSession($, { id, path, overwrite: false }, loaded.note)
73    return loaded.note
74  }
75  const copy = loaded.text === undefined ? null : await keepCopy($, path, loaded.text, isoAt(await $.clock.now()))
76  if (copy === null) $.ui.log(`could not keep a copy of ${path}: ${loaded.reason}`, { to: 'debug' })
77  else $.ui.toast(text.unreadable)
78  // 사본을 못 남겼으면 원본을 지킨다 — 저장은 실패로 둔다
79  await showSession($, { id, path, overwrite: copy !== null }, emptyNote())
80  return emptyNote()
81}
82
83// 패널이 닫혔다 — 초안을 버린다
84export async function paneClosed($: EngineInterface) {
85  await update($, paneOpen, () => false)
86  await dropDrafts($)
87}
88
89// 초안을 모두 버린다(패널 닫기 등)
90export async function dropDrafts($: EngineInterface) {
91  await setView($, view => ({ ...view, adding: null, editing: null, armedId: null }))
92}
93
94export async function openPane($: EngineInterface, title: string) {
95  await $.ui.open({ id: PANE, title })
96  await update($, paneOpen, () => true)
97}
98
99// 수정 칸의 key — 끝의 editKey는 수정을 저장할 때마다 바뀐다(types View.editKey)
100export const editFieldKey = (field: keyof Draft, editKey: string) => `edit-${field}-${editKey}`
101
102// [수정]을 누르면 그 항목을 수정 칸으로 바꾸고, 패널이 키보드를 받아 제목 칸 끝에 커서를 둔다.
103// 패널은 누른 즉시 다시 연다 — 기다린 뒤 열면 좁은 터미널에서 엔진이 누름에 대한 열기로 보지 않는다(TASK-010).
104// 채팅 입력창에 치다 만 글이 있으면 엔진이 키보드를 넘기지 않는다 — 커서 옮기기는 덤이라, 거절되거나 실패해도 수정 칸은 그대로 둔다
105export async function startEdit($: EngineInterface, title: string, item: Item) {
106  const opened = $.ui.open({ id: PANE, title, focus: true })
107  await setView($, view => ({
108    ...view,
109    adding: null,
110    armedId: null,
111    editing: { id: item.id, title: item.title, body: item.body },
112  }))
113  await opened
114  const { editKey } = await readView($)
115  await $.ui.focus({ requestId: PANE, key: editFieldKey('title', editKey) }).catch(() => undefined)
116}
117
118// 데스크톱 앱은 누른 버튼이 누른 결과로 사라지면 다음 첫 클릭에 반응하지 않는다(TASK-014). 동작이 끝나면 커서를
119// 그 화면(site: 패널 id 또는 띠의 requestId)에 남는 버튼(key)으로 옮긴다 — 덤이라, 거절되거나 실패해도 그대로 둔다
120export async function focusAfter($: EngineInterface, site: string, key: string, action: Promise<unknown>) {
121  await action
122  await $.ui.focus({ requestId: site, key }).catch(() => undefined)
123}
124
125// isOpen: 띠가 그릴 때 읽은 열림 상태. 열 때는 기다림 없이 $.ui.open부터 불러야 엔진이 누름에 대한 열기로
126// 보고 폭과 상관없이 배치한다 — 기다린 뒤 열면 좁은 터미널(110칸 미만)에서 그리지 않는다 (spec UI.md 2절).
127// tab: 그 탭으로 연다(띠의 보기 → 할 일). 없으면 마지막으로 고른 탭 그대로
128export async function togglePane($: EngineInterface, title: string, isOpen: boolean, tab?: Tab) {
129  if (!isOpen) {
130    const opened = openPane($, title)
131    if (tab !== undefined) await setView($, view => ({ ...view, tab }))
132    return opened
133  }
134  // 자기 $.ui.close에는 자기 ui.close 훅이 돌지 않을 수 있어 여기서도 닫힘을 기록한다
135  await $.ui.close({ id: PANE })
136  await paneClosed($)
137}
138
139// 노트를 바꿔 저장한다. 저장에 성공하면 화면 값을 바꾸고 저장된 노트를, 노트가 열리지 않았거나 실패하면 null
140export async function saveChange($: EngineInterface, edit: (n: Note) => Note): Promise<Note | null> {
141  const opened = await read($, session)
142  if (opened === null) return null
143  try {
144    const saved = await saveNote($, opened.path, edit, opened.overwrite)
145    await update($, note, () => saved)
146    if (opened.overwrite) await update($, session, () => ({ ...opened, overwrite: false }))
147    return saved
148  } catch {
149    return null
150  }
151}
152
153// 사용자가 화면에서 바꾼 것 — 실패하면 토스트를 띄운다
154async function change($: EngineInterface, text: Text, edit: (n: Note) => Note): Promise<Note | null> {
155  const saved = await saveChange($, edit)
156  if (saved === null) $.ui.toast(text.saveFailed)
157  return saved
158}
159
160const withItem = (n: Note, id: string, edit: (item: Item) => Item): Note => ({
161  ...n,
162  items: n.items.map(item => (item.id === id ? edit(item) : item)),
163})
164
165const itemIn = (n: Note, id: string) => n.items.find(item => item.id === id)
166
167// 항목을 완료한다. 그 항목의 완료 제안은 없앤다(spec DATA.md 2절)
168export const completeIn = (n: Note, id: string, at: string): Note => ({
169  ...withItem(n, id, item => (item.status === 'open' ? { ...item, status: 'done', doneAt: at } : item)),
170  proposals: n.proposals.filter(p => !(p.kind === 'done' && p.itemId === id)),
171})
172
173// 완료한 항목을 다시 연다
174export const reopenIn = (n: Note, id: string): Note =>
175  withItem(n, id, item => (item.status === 'done' ? { ...item, status: 'open', doneAt: null } : item))
176
177// 항목과 그 항목을 가리키는 완료 제안을 지운다
178export const removeIn = (n: Note, id: string): Note => ({
179  ...n,
180  items: n.items.filter(item => item.id !== id),
181  proposals: n.proposals.filter(p => !(p.kind === 'done' && p.itemId === id)),
182})
183
184// 제목·내용을 고친다. 빈 제목과 주지 않은 내용은 그대로 둔다
185export const editIn = (n: Note, id: string, title: string, body?: string): Note =>
186  withItem(n, id, item => ({ ...item, title: title === '' ? item.title : title, body: body ?? item.body }))
187
188// 제안을 받는다: 새 항목 제안은 받은 시각의 항목(올린 경로 proposal)이 되고, 완료 제안은 그 항목을 완료한다
189export function acceptIn(n: Note, proposal: Proposal, at: string, itemId: string): Note {
190  const rest = { ...n, proposals: n.proposals.filter(p => p.id !== proposal.id) }
191  if (proposal.kind === 'done') return completeIn(rest, proposal.itemId, at)
192  const item: Item = {
193    id: itemId,
194    title: proposal.title,
195    body: proposal.body,
196    source: 'proposal',
197    status: 'open',
198    createdAt: at,
199    doneAt: null,
200  }
201  return { ...rest, items: [...rest.items, item] }
202}
203
204export async function toggleDone($: EngineInterface, text: Text, id: string) {
205  const at = isoAt(await $.clock.now())
206  const saved = await change($, text, n => (itemIn(n, id)?.status === 'open' ? completeIn(n, id, at) : reopenIn(n, id)))
207  if (saved === null) return
208  await setView($, view => (view.openId === id ? { ...view, openId: null, editing: null, armedId: null } : view))
209  const item = itemIn(saved, id)
210  if (item) await tell($, item.status === 'done' ? told.completed(item) : told.reopened(item))
211}
212
213// 빈 제목은 추가하지 않고 초안을 그대로 둔다
214export async function addItem($: EngineInterface, text: Text, draft: Draft) {
215  const title = draft.title.trim()
216  if (title === '') return
217  const at = isoAt(await $.clock.now())
218  const item: Item = {
219    id: newId(),
220    title,
221    body: draft.body.trim(),
222    source: 'user',
223    status: 'open',
224    createdAt: at,
225    doneAt: null,
226  }
227  const saved = await change($, text, n => ({ ...n, items: [...n.items, item] }))
228  if (saved === null) return
229  await setView($, view => ({ ...view, adding: null }))
230  await tell($, told.added(item))
231}
232
233// 제목을 비우고 저장하면 제목은 그대로 두고 내용만 저장한다
234export async function saveEdit($: EngineInterface, text: Text, id: string, draft: Draft) {
235  const title = draft.title.trim()
236  const saved = await change($, text, n => editIn(n, id, title, draft.body.trim()))
237  if (saved === null) return
238  await setView($, view => ({ ...view, editing: null, editKey: newId() }))
239  const item = itemIn(saved, id)
240  if (item) await tell($, told.edited(item))
241}
242
243// 항목과 그 항목을 가리키는 제안을 완전히 지운다
244export async function removeItem($: EngineInterface, text: Text, id: string) {
245  const before = itemIn(await read($, note), id)
246  const saved = await change($, text, n => removeIn(n, id))
247  if (saved === null) return
248  await setView($, view => ({ ...view, openId: null, armedId: null, editing: null }))
249  if (before) await tell($, told.removed(before))
250}
251
252// 세션이 지운 항목의 펼침·초안을 닫는다
253export async function forgetItem($: EngineInterface, id: string) {
254  await setView($, view => ({
255    ...view,
256    openId: view.openId === id ? null : view.openId,
257    armedId: view.armedId === id ? null : view.armedId,
258    editing: view.editing?.id === id ? null : view.editing,
259  }))
260}
261
262// 띠·패널의 추가·완료 — 제안을 받는다
263export async function acceptProposal($: EngineInterface, text: Text, id: string) {
264  const proposal = (await read($, note)).proposals.find(p => p.id === id)
265  if (proposal === undefined) return
266  const at = isoAt(await $.clock.now())
267  const itemId = newId()
268  const saved = await change($, text, n => {
269    const latest = n.proposals.find(p => p.id === id)
270    return latest === undefined ? n : acceptIn(n, latest, at, itemId)
271  })
272  if (saved === null) return
273  const item = itemIn(saved, proposal.kind === 'new' ? itemId : proposal.itemId)
274  if (item) await tell($, proposal.kind === 'new' ? told.accepted(item) : told.confirmed(item))
275}
276
277// 띠·패널의 무시·아직 — 제안만 없앤다
278export async function dismissProposal($: EngineInterface, text: Text, id: string) {
279  const proposal = (await read($, note)).proposals.find(p => p.id === id)
280  if (proposal === undefined) return
281  const saved = await change($, text, n => ({ ...n, proposals: n.proposals.filter(p => p.id !== id) }))
282  if (saved === null) return
283  if (proposal.kind === 'new') return tell($, told.dismissed(proposal.title))
284  const item = itemIn(saved, proposal.itemId)
285  if (item) await tell($, told.notYet(item))
286}
287
hooks/process.ts 184 lines
1// 과정의 데이터 규칙 — 깊이 3단, 위 단계 상태 계산, 진행 중 과정 하나, 아래 단계의 진행 중 이어받기, 화면 순번과 지금 단계 (spec AGENT.md 6-3, DATA.md 4절)
2// 코드는 데이터 모양만 지킨다. 진행 중 단계의 개수·위치는 막지 않는다.
3// 이름에 process를 쓰지 않는다(Node 전역 process를 가린다) — 과정 하나는 proc으로 부른다
4
5import type { Note, Process, Step, StepStatus } from '../types'
6
7// 과정 단계의 깊이는 최대 3단이다
8export const MAX_DEPTH = 3
9
10export const STEP_STATUSES: readonly StepStatus[] = ['waiting', 'active', 'done', 'stopped']
11
12const isOver = (status: StepStatus) => status === 'done' || status === 'stopped'
13
14// 아래 단계가 있는 단계의 상태는 아래 단계로 정해진다: 모두 완료 → 완료, 모두 완료·중단이고 중단이 있으면 → 중단,
15// 하나라도 시작했으면 → 진행 중, 아니면 → 대기. 파일에 적힌 위 단계 값에 기대지 않는다
16export function statusOf(step: Step): StepStatus {
17  if (step.steps.length === 0) return step.status
18  const below = step.steps.map(statusOf)
19  if (below.every(status => status === 'done')) return 'done'
20  if (below.every(isOver)) return 'stopped'
21  if (below.some(status => status !== 'waiting')) return 'active'
22  return 'waiting'
23}
24
25// 위 단계에 계산한 상태를 적어 둔다 — 파일을 읽는 사람이 헷갈리지 않게
26export const settle = (steps: Step[]): Step[] =>
27  steps.map(step => {
28    if (step.steps.length === 0) return step
29    const below = settle(step.steps)
30    return { ...step, steps: below, status: statusOf({ ...step, steps: below }) }
31  })
32
33// 단계가 있고 모두 완료·중단인 과정은 끝난 과정이다(지우지 않고 화면에서 숨긴다)
34export const isFinished = (proc: Process) => proc.steps.length > 0 && proc.steps.map(statusOf).every(isOver)
35
36// 끝나지 않은 진행 중 과정 — 많아야 하나
37export const activeProcess = (note: Note) => note.processes.find(proc => proc.status === 'active' && !isFinished(proc))
38
39// 진행 중 과정, 대기 과정(만든 순), 끝난 과정(만든 순) 순서
40export function processesInOrder(note: Note): Process[] {
41  const active = activeProcess(note)
42  const byAge = [...note.processes].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
43  const waiting = byAge.filter(proc => proc !== active && !isFinished(proc))
44  const finished = byAge.filter(isFinished)
45  return [...(active === undefined ? [] : [active]), ...waiting, ...finished]
46}
47
48// 다른 과정을 진행 중으로 바꾸면 하던 과정은 대기가 된다. 단계 상태는 그대로 둔다(돌아올 자리)
49export const activate = (note: Note, id: string): Note => ({
50  ...note,
51  processes: note.processes.map(proc => ({ ...proc, status: proc.id === id ? 'active' : 'waiting' })),
52})
53
54export const withProcess = (note: Note, id: string, change: (proc: Process) => Process): Note => ({
55  ...note,
56  processes: note.processes.map(proc => (proc.id === id ? change(proc) : proc)),
57})
58
59// 화면과 같은 순번(1, 2, 2.1, 2.1.1)을 단 단계 목록 — 자리 순서대로 매기고 저장하지 않는다
60export type Numbered = { step: Step; number: string; depth: number; parentId: string | null; path: number[] }
61
62export function numbered(steps: Step[], parentId: string | null = null, path: number[] = []): Numbered[] {
63  return steps.flatMap((step, index) => {
64    const here = [...path, index + 1]
65    return [
66      { step, number: here.join('.'), depth: here.length, parentId, path: here },
67      ...numbered(step.steps, step.id, here),
68    ]
69  })
70}
71
72const sameRef = (id: string, ref: string) => ref !== '' && (id === ref || id.slice(0, 8) === ref)
73
74// 숨은 번호(8글자·전체 id) 또는 화면 순번("3", "2.1")으로 단계를 찾는다
75export function findStep(proc: Process, ref: string): Numbered | undefined {
76  const all = numbered(proc.steps)
77  return all.find(one => sameRef(one.step.id, ref)) ?? all.find(one => one.number === ref)
78}
79
80export const findProcess = (note: Note, ref: string) => note.processes.find(proc => sameRef(proc.id, ref))
81
82// 단계 아래로 몇 단이 있는지(아래 단계가 없으면 1)
83export const heightOf = (step: Step): number => 1 + Math.max(0, ...step.steps.map(heightOf))
84
85export const leavesOf = (step: Step): Step[] => (step.steps.length === 0 ? [step] : step.steps.flatMap(leavesOf))
86
87// 지금 단계 — 진행 중인 끝 단계가 하나면 그 단계, 여럿이고 같은 1단 안이면 그들을 묶는 가장 가까운 위 단계,
88// 서로 다른 1단에 흩어져 있으면 앞쪽 하나와 나머지 개수(띠의 "외 N"). 진행 중 단계가 없으면 null (spec UI.md 6절)
89export function currentOf(proc: Process): { at: Numbered; others: number } | null {
90  const all = numbered(proc.steps)
91  const active = all.filter(one => one.step.steps.length === 0 && one.step.status === 'active')
92  if (active.length === 0) return null
93  const groups = new Map<number, Numbered[]>()
94  for (const one of active) groups.set(one.path[0] ?? 0, [...(groups.get(one.path[0] ?? 0) ?? []), one])
95  const [first = []] = groups.values()
96  const at = all.find(one => one.number === commonPath(first.map(one => one.path)).join('.'))
97  return at === undefined ? null : { at, others: groups.size - 1 }
98}
99
100// 여러 순번이 함께 시작하는 가장 긴 앞부분 — 하나면 그 순번 그대로
101function commonPath(paths: number[][]): number[] {
102  const [head = [], ...rest] = paths
103  let n = 0
104  while (n < head.length && rest.every(path => path[n] === head[n])) n++
105  return head.slice(0, n)
106}
107
108// 대기 과정이 멈춘 자리 — 지금 단계, 없으면 끝나지 않은 첫 끝 단계
109export function stoppedAt(proc: Process): Numbered | undefined {
110  const current = currentOf(proc)
111  if (current !== null) return current.at
112  return numbered(proc.steps).find(one => one.step.steps.length === 0 && !isOver(one.step.status))
113}
114
115// 단계 목록 안에서 id인 단계를 고친다(어느 깊이든)
116const mapStep = (steps: Step[], id: string, change: (step: Step) => Step): Step[] =>
117  steps.map(step => (step.id === id ? change(step) : { ...step, steps: mapStep(step.steps, id, change) }))
118
119const dropSteps = (steps: Step[], ids: ReadonlySet<string>): Step[] =>
120  steps.filter(step => !ids.has(step.id)).map(step => ({ ...step, steps: dropSteps(step.steps, ids) }))
121
122// 첫 끝 단계를 진행 중으로 — 이어받기에 쓴다
123const startFirst = (step: Step): Step =>
124  step.steps.length === 0
125    ? { ...step, status: 'active' }
126    : { ...step, steps: step.steps.map((one, index) => (index === 0 ? startFirst(one) : one)) }
127
128// parentId(없으면 1단) 아래 index 자리에 단계를 넣는다. 진행 중인 끝 단계에 처음 아래 단계를 넣고 넣는 단계가 모두
129// 대기면 첫 단계가 진행 중을 이어받는다 — 위 단계가 대기로 뒤집혀 지금 단계가 사라지지 않게
130export function insertSteps(proc: Process, parentId: string | null, index: number, added: Step[]): Process {
131  const place = (siblings: Step[]) => [...siblings.slice(0, index), ...added, ...siblings.slice(index)]
132  if (parentId === null) return { ...proc, steps: settle(place(proc.steps)) }
133  const steps = mapStep(proc.steps, parentId, parent => {
134    const inherits =
135      parent.steps.length === 0 && parent.status === 'active' && added.every(step => statusOf(step) === 'waiting')
136    const below = inherits ? added.map((step, n) => (n === 0 ? startFirst(step) : step)) : added
137    return { ...parent, steps: [...parent.steps.slice(0, index), ...below, ...parent.steps.slice(index)] }
138  })
139  return { ...proc, steps: settle(steps) }
140}
141
142// 단계를 빼면 그 아래 단계도 함께 빠진다. 아래 단계가 모두 빠진 위 단계는 마지막 계산 상태를 제 상태로 남긴다
143export const removeSteps = (proc: Process, ids: ReadonlySet<string>): Process => ({
144  ...proc,
145  steps: settle(dropSteps(proc.steps, ids)),
146})
147
148export function editStep(proc: Process, id: string, title: string, memo?: string): Process {
149  return {
150    ...proc,
151    steps: mapStep(proc.steps, id, step => ({ ...step, title: title === '' ? step.title : title, memo: memo ?? step.memo })),
152  }
153}
154
155// 단계 하나를 다른 자리로 옮긴다(아래 단계와 함께). index는 단계를 뺀 뒤의 자리다
156export function moveStep(proc: Process, id: string, parentId: string | null, index: number): Process {
157  const moving = findStep(proc, id)?.step
158  if (moving === undefined) return proc
159  return insertSteps(removeSteps(proc, new Set([id])), parentId, index, [moving])
160}
161
162// 끝 단계는 그 상태로. 아래 단계가 있는 단계는 거절하지 않고 그 아래 끝 단계들에 적용한다 — 완료·중단 = 끝나지 않은
163// 끝 단계 모두, 진행 중 = 끝나지 않은 첫 끝 단계(이미 진행 중인 것이 있으면 그대로), 대기 = 진행 중인 끝 단계들
164export function setStatus(proc: Process, id: string, status: StepStatus): Process {
165  const steps = mapStep(proc.steps, id, step => {
166    if (step.steps.length === 0) return { ...step, status }
167    const leaves = leavesOf(step)
168    const open = leaves.filter(leaf => !isOver(leaf.status))
169    const targets =
170      status === 'active'
171        ? open.some(leaf => leaf.status === 'active')
172          ? []
173          : open.slice(0, 1)
174        : status === 'waiting'
175          ? leaves.filter(leaf => leaf.status === 'active')
176          : open
177    const ids = new Set(targets.map(leaf => leaf.id))
178    const apply = (one: Step): Step =>
179      ids.has(one.id) ? { ...one, status } : { ...one, steps: one.steps.map(apply) }
180    return apply(step)
181  })
182  return { ...proc, steps: settle(steps) }
183}
184
hooks/note.ts 63 lines
1// 노트·항목·제안·과정의 형식과 파일 글자 ↔ 노트 변환 (spec DATA.md 2·4절)
2
3import type { Item, Note, Process, Proposal } from '../types'
4
5export type { Item, ItemSource, Note, Process, Proposal, Step, StepStatus } from '../types'
6
7export const emptyNote = (): Note => ({ version: 1, items: [], proposals: [], processes: [] })
8
9export const isEmpty = (note: Note) =>
10  note.items.length === 0 && note.proposals.length === 0 && note.processes.length === 0
11
12export const newId = () => crypto.randomUUID()
13
14// 열린 항목은 먼저 올라온 것이 위, 완료 항목은 최근에 끝난 것이 위
15export const openItems = (n: Note) =>
16  n.items.filter(item => item.status === 'open').sort((a, b) => a.createdAt.localeCompare(b.createdAt))
17
18export const doneItems = (n: Note) =>
19  n.items.filter(item => item.status === 'done').sort((a, b) => (b.doneAt ?? '').localeCompare(a.doneAt ?? ''))
20
21// 완료 제안의 대상이 지워졌거나 이미 완료면 그 제안은 없는 것으로 본다(spec DATA.md 2절)
22export const liveProposals = (n: Note) =>
23  n.proposals.filter(p => p.kind === 'new' || n.items.some(item => item.id === p.itemId && item.status === 'open'))
24
25// 시간대를 붙인 ISO 형식: 2026-10-15T14:32:00+09:00
26export function isoAt(ms: number): string {
27  const d = new Date(ms)
28  const offset = -d.getTimezoneOffset()
29  const local = new Date(ms + offset * 60_000).toISOString().slice(0, 19)
30  const sign = offset >= 0 ? '+' : '-'
31  const hh = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0')
32  const mm = String(Math.abs(offset) % 60).padStart(2, '0')
33  return `${local}${sign}${hh}:${mm}`
34}
35
36export type Parsed = { ok: true; note: Note } | { ok: false; reason: string }
37
38export function parseNote(text: string): Parsed {
39  let raw: unknown
40  try {
41    raw = JSON.parse(text)
42  } catch {
43    return { ok: false, reason: 'not JSON' }
44  }
45  if (typeof raw !== 'object' || raw === null) return { ok: false, reason: 'not an object' }
46  const { version, items, proposals, processes } = raw as Record<string, unknown>
47  if (version !== 1) return { ok: false, reason: `unknown version ${String(version)}` }
48  if (!Array.isArray(items) || !Array.isArray(proposals)) return { ok: false, reason: 'missing lists' }
49  // processes가 없는 1.0.x 파일은 과정이 빈 노트다
50  if (processes !== undefined && !Array.isArray(processes)) return { ok: false, reason: 'processes is not a list' }
51  return {
52    ok: true,
53    note: {
54      version: 1,
55      items: items as Item[],
56      proposals: proposals as Proposal[],
57      processes: (processes ?? []) as Process[],
58    },
59  }
60}
61
62export const formatNote = (note: Note) => `${JSON.stringify(note, null, 2)}\n`
63
hooks/process-pane.tsx 139 lines
1// 패널의 과정 탭 — 진행 중 과정(펼침), 대기 과정(한 줄 + 멈춘 자리), 끝난 과정 묶음, 단계 접기. 보기·펼치기만 한다 (spec UI.md 2절)
2import type { ClientElements, EngineInterface, RenderChildren } from 'claude-code'
3
4import type { Note, Process, Step } from './note'
5import { activeProcess, currentOf, isFinished, numbered, processesInOrder, statusOf, stoppedAt } from './process'
6import { SQUARE } from './text'
7import type { Text } from './text'
8import { setView, type View } from './view'
9
10type Parts = Pick<ClientElements, 'Box' | 'Button' | 'Text'>
11
12// 과정 탭 본문의 구획들 — 패널이 구획 사이에 한 줄 여백을 둔다. rule: 패널 끝까지 닿는 구분선
13export function drawProcessTab(
14  $: EngineInterface,
15  parts: Parts,
16  text: Text,
17  note: Note,
18  view: View,
19  rule: RenderChildren,
20): RenderChildren[] {
21  const { Box, Button, Text: T } = parts
22  const active = activeProcess(note)
23  const others = processesInOrder(note).filter(proc => proc !== active)
24  const waiting = others.filter(proc => !isFinished(proc))
25  const finished = others.filter(isFinished)
26  // 지금 단계(굵은 제목)는 진행 중 과정에만 있다
27  const currentId = active === undefined ? null : (currentOf(active)?.at.step.id ?? null)
28  // 펼침 — 사용자가 누른 값, 없으면 기본(진행 중인 단계만 펼침, 과정은 접음)
29  const isUnfolded = (id: string, byDefault: boolean) => view.folds[id] ?? byDefault
30  const flip = (id: string, shown: boolean) =>
31    void setView($, v => ({ ...v, folds: { ...v.folds, [id]: !shown } }))
32  const arrow = (shown: boolean) => (shown ? '▾' : '▸')
33  const square = (status: Step['status']) => <T color={SQUARE[status].color}>{SQUARE[status].label}</T>
34
35  // 단계 줄 `<네모> <순번> <제목>` — 깊이만큼 들여 쓰고, 아래 단계가 있으면 제목을 눌러 접고 편다
36  const stepRows = (proc: Process): RenderChildren[] => {
37    const rows: RenderChildren[] = []
38    const numberOf = new Map(numbered(proc.steps).map(one => [one.step.id, one.number]))
39    const walk = (steps: Step[], depth: number) => {
40      for (const step of steps) {
41        const number = numberOf.get(step.id) ?? ''
42        const status = statusOf(step)
43        const hasBelow = step.steps.length > 0
44        const shown = hasBelow && isUnfolded(step.id, status === 'active')
45        const title = (
46          <T bold={step.id === currentId ? true : undefined} wrap="wrap">
47            {step.title}
48            {hasBelow ? <T dimColor> {arrow(shown)}</T> : ''}
49          </T>
50        )
51        rows.push(
52          <Box key={`step-${step.id}`} flexDirection="row" gap={1} paddingLeft={depth * 2} alignItems="flex-start">
53            <Box flexShrink={0}>
54              <T>
55                {square(status)} <T dimColor>{number}</T>
56              </T>
57            </Box>
58            <Box flexDirection="row" flexShrink={1} minWidth={0}>
59              {hasBelow ? (
60                <Button key={`fold-${step.id}`} plain onPress={() => flip(step.id, shown)}>
61                  {title}
62                </Button>
63              ) : (
64                title
65              )}
66            </Box>
67          </Box>,
68        )
69        if (shown) walk(step.steps, depth + 1)
70      }
71    }
72    walk(proc.steps, 1)
73    return rows
74  }
75
76  // 대기·끝난 과정의 머리줄 — 누르면 단계를 펼쳐 보기만 하고 진행 중 과정은 바뀌지 않는다
77  const folded = (proc: Process, status: Step['status'], aside: string) => {
78    const shown = isUnfolded(proc.id, false)
79    return [
80      <Box key={`proc-${proc.id}`} flexDirection="row" gap={1} alignItems="flex-start">
81        <Box flexShrink={0}>{square(status)}</Box>
82        <Box flexDirection="row" flexShrink={1} minWidth={0}>
83          <Button key={`fold-${proc.id}`} plain onPress={() => flip(proc.id, shown)}>
84            <T wrap="wrap">
85              <T bold>{proc.title}</T>
86              <T dimColor> {arrow(shown)}</T>
87              {aside === '' ? '' : <T dimColor>{`  ${aside}`}</T>}
88            </T>
89          </Button>
90        </Box>
91      </Box>,
92      ...(shown ? stepRows(proc) : []),
93    ]
94  }
95
96  const sections: RenderChildren[] = []
97  if (active !== undefined)
98    sections.push(
99      <Box key="active" flexDirection="column">
100        <Box key={`proc-${active.id}`} flexDirection="row" gap={1} alignItems="flex-start">
101          <Box flexShrink={0}>{square('active')}</Box>
102          <T bold wrap="wrap">
103            {active.title}
104          </T>
105        </Box>
106        {stepRows(active)}
107      </Box>,
108    )
109  if (waiting.length > 0)
110    sections.push(
111      <Box key="waiting" flexDirection="column">
112        {waiting.flatMap(proc => {
113          const at = stoppedAt(proc)
114          return folded(proc, 'waiting', at === undefined ? '' : text.pausedAt(at.number))
115        })}
116      </Box>,
117    )
118  if (sections.length === 0) sections.push(<T dimColor>{text.noProcesses}</T>)
119  if (finished.length > 0)
120    sections.push(
121      <Box key="finished" flexDirection="column">
122        {rule}
123        <Button
124          key="finished-group"
125          plain
126          dimColor
127          label={`${text.finished(finished.length)} ${arrow(view.showFinished)}`}
128          onPress={() => void setView($, v => ({ ...v, showFinished: !v.showFinished }))}
129        />
130        {view.showFinished
131          ? finished.flatMap(proc =>
132              folded(proc, proc.steps.map(statusOf).every(status => status === 'done') ? 'done' : 'stopped', ''),
133            )
134          : []}
135      </Box>,
136    )
137  return sections
138}
139
hooks/store.ts 53 lines
1// 세션마다 노트 파일 하나를 읽고 쓴다 — 쓸 때마다 다시 읽고 변경만 얹는다 (spec DATA.md 3절)
2import type { EngineInterface } from 'claude-code'
3
4import { dataDir } from './data-dir'
5import { emptyNote, formatNote, isEmpty, parseNote, type Note } from './note'
6
7// 읽을 수 없으면 읽은 글(있으면)을 함께 돌려준다 — 사본을 남기는 데 쓴다
8export type Loaded = { ok: true; note: Note } | { ok: false; path: string; reason: string; text?: string }
9
10export async function notePath($: EngineInterface, sessionId: string): Promise<string> {
11  return `${await dataDir($)}/sessions/${sessionId}.json`
12}
13
14// 파일이 없으면 빈 노트다. 읽을 수 없으면 실패를 알린다
15export async function loadNote($: EngineInterface, path: string): Promise<Loaded> {
16  if (!(await $.fs.exists(path))) return { ok: true, note: emptyNote() }
17  let text: string
18  try {
19    text = await $.fs.read(path)
20  } catch (error) {
21    return { ok: false, path, reason: String(error) }
22  }
23  const parsed = parseNote(text)
24  return parsed.ok ? parsed : { ok: false, path, reason: parsed.reason, text }
25}
26
27// 읽을 수 없는 파일의 사본을 같은 폴더에 <세션ID>.unreadable-<일시>.json으로 남긴다. 남긴 경로, 실패하면 null
28export async function keepCopy($: EngineInterface, path: string, text: string, at: string): Promise<string | null> {
29  const copy = `${path.replace(/\.json$/, '')}.unreadable-${at.slice(0, 19).replace(/[-:]/g, '')}.json`
30  try {
31    await $.fs.write(copy, text)
32    return copy
33  } catch {
34    return null
35  }
36}
37
38// 최신 파일 위에 change를 얹어 저장하고 결과 노트를 돌려준다. overwrite면 읽지 않고 빈 노트 위에 얹는다(사본을 남긴 원본 자리).
39// 파일이 없고 결과도 비어 있으면 파일을 만들지 않는다. 읽기·쓰기 실패는 호출한 쪽으로 던진다
40export async function saveNote(
41  $: EngineInterface,
42  path: string,
43  change: (note: Note) => Note,
44  overwrite = false,
45): Promise<Note> {
46  const loaded = overwrite ? { ok: true as const, note: emptyNote() } : await loadNote($, path)
47  if (!loaded.ok) throw new Error(`cannot read ${path}: ${loaded.reason}`)
48  const next = change(loaded.note)
49  if (isEmpty(next) && !(await $.fs.exists(path))) return next
50  await $.fs.write(path, formatNote(next))
51  return next
52}
53