@lef237/hagumi 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lef237
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,311 @@
1
- # Temporary Holding Version
1
+ # hagumi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 一緒に育つ、楽しいコーディングの相棒(開発中)。
4
+
5
+ ## インストール
6
+
7
+ hagumi は [Bun](https://bun.sh)(1.3 以上)で動く。Bun を入れてから、次のようにする。
8
+
9
+ ```sh
10
+ bun add -g @lef237/hagumi
11
+ hagumi login
12
+ hagumi
13
+ ```
14
+
15
+ 試すだけなら、入れずに `bunx @lef237/hagumi` でも動く。
16
+
17
+ npm では `hagumi` という名前が既存のパッケージ(`wagmi`)と似ているとして使えないため、パッケージ名は `@lef237/hagumi` にしている。入れたあとのコマンドは `hagumi`。
18
+
19
+ ## いまできること
20
+
21
+ ```sh
22
+ hagumi login # Sign in with ChatGPT(ブラウザが開く)
23
+ hagumi login opencode-go # OpenCode Go の API キーを保存する(入力は画面に出ない)
24
+ hagumi models [provider] # 使えるモデルの一覧(省略するとサインイン済みのすべて)
25
+ hagumi # 抱えているプロジェクトを見渡して、どこから始めるか選ぶ
26
+ hagumi . # ここで対話を始める(Ctrl+D で終わる)
27
+ hagumi new # 新しい作業の場所を作って、そこで対話を始める
28
+ hagumi prune # new が作ったまま空の作業の場所を、まとめて片付ける
29
+ hagumi ask "こんにちは" # 1回だけ問い合わせる
30
+ hagumi ask --session <id> "続きの質問" # 前のセッションの続きとして問い合わせる
31
+ hagumi inspect <id> # そのセッションで最後にモデルへ送ったリクエストを表示する
32
+ hagumi config # 設定ファイルの場所と、いま効いている設定を表示する
33
+ hagumi memory # 覚えていることと、最近の変更を表示する
34
+ ```
35
+
36
+ - モデルは `--model <provider>/<model>` で選ぶ。省略すると、設定の `model`(既定は `chatgpt/gpt-6-luna`)を使う。例: `--model opencode-go/kimi-k3`
37
+ - `hagumi` だけで起動すると、時間帯に合わせてあいさつし、ボードのあるプロジェクトを、最近動いた順に9つまで並べる。番号で選ぶとそのプロジェクトで、`n` で新しい場所で、`.` でいまの場所で、対話を始める。それより古いプロジェクトは一覧に出ない(これがアーカイブの代わり)。覚えていること(名前、日付、どんな作業だったか)を打つと、相棒がすべてのボードから探す。端末がないとき(パイプで実行したときなど)は、一覧を表示するだけ。
38
+
39
+ ```text
40
+ (・ω・)ノ おはよう、はぐみだよ。今日もよろしくね。
41
+
42
+ 1. my-app いま: README を見直し、改善点を整理中 次: 圧縮の要約を作る
43
+ 2. blog 次: 記事の下書き
44
+ n. 新しく始める
45
+ . ここで始める(~/src/my-app)
46
+
47
+ (・ω・)? どこから始める? 1
48
+ ```
49
+
50
+ - プロジェクトを選ぶと、そこで新しい会話を始める。続きは、ボードと記憶が引き継ぐ。
51
+ - `hagumi .` も `--session <id>` で前の会話を続けられる。`--session` で再開したときは「おかえり」と言う。会話の途中でプロバイダを替えてもよい。
52
+ - どこで作業しているかが、いつも分かる。始めるときに作業の場所のパスを表示し、入力の印にはディレクトリ名が付く(例: `my-app> `)。
53
+ - `new` は、`~/hagumi-work/2026-10-12/13-24/` のような日付と時刻のディレクトリを作り、そこで対話を始める。
54
+ - 作ったディレクトリでは git を始めておく(サンドボックスの中のモデルは `git init` できないため)。
55
+ - 同じ分にもう一度始めると、`13-24-2` のように番号が付く。
56
+ - 置き場所は設定の `workspaces` で変えられる。`--model` も使える。
57
+ - 作った場所は、始めるときにパスだけを1行で表示する。チャットのあいだに、別のタブで開ける。
58
+ - チャットの中では、移動しなくても、ファイルの編集やコマンド、`git add`・`git commit` ができる(`push` などネットワークを使う操作はできない)。
59
+ - hagumi が終わっても、シェルのいる場所は変わらない(プログラムはシェルを移せない)。終わるときに「移るなら: cd …」と表示する。
60
+ - 終わるときに、その場所が空のまま(`.git` のほかに何もなく、コミットもない)なら、「片付ける?」と聞く。会話はログに残るので、消えるのは空のディレクトリだけ。
61
+ - 片付けずに残った空の場所は、`hagumi prune` で一覧を見て、まとめて片付けられる。見るのは `new` が付ける形の名前(`2026-10-12/13-24` など)だけで、ほかの名前のディレクトリには触れない。いま使っている場所も外す。
62
+ - 終わったあとシェルも移ってほしいときは(任意)、次のような関数をシェルの設定ファイル(`~/.zshrc` など)に書いておく。`hagumi` をコマンドとして入れている場合の例で、`workspaces` を変えたときは、パスも合わせる。
63
+
64
+ ```sh
65
+ hnew() { hagumi new "$@" && cd "$(ls -dt ~/hagumi-work/*/*/ | head -n 1)"; }
66
+ ```
67
+ - 対話の中では、次のことができる。
68
+ - `/help` で、コマンドの一覧を出す。
69
+ - `/models [provider]` で、使えるモデルを一覧する。いまのモデルには `*` が付く。
70
+ - `/model <provider>/<model>` で、次の発言からモデルを切り替える。`/model` だけなら、いまのモデルを表示する。
71
+ - `/language auto|ja|en` で、言語を切り替える。画面の文言はすぐに、相棒の話す言語は次の発言から変わる。このチャットの間だけ有効(ずっと変えるなら設定の `language`)。`/language` だけなら、いまの言語を表示する。
72
+ - ←→ でカーソルを動かし、↑↓ で入力の履歴を呼び出す。`--session` で再開したときは、そのセッションでの過去の入力も呼び出せる。
73
+ - 返答中に打った行は、順番待ちになる。返答中に Ctrl+D を押した場合は、その返答が終わってから終了する。
74
+
75
+ ## 表情
76
+
77
+ hagumi は、そのときの様子を表情で見せる。
78
+
79
+ | 様子 | 顔文字(既定) |
80
+ |---|---|
81
+ | あいさつ | `(・ω・)ノ` |
82
+ | 考え中(返答が始まると消える) | `(・_・)…` |
83
+ | ツールを使っている | `(`・ω・´)` |
84
+ | 承認を待っている | `(・ω・)?` |
85
+ | 失敗した | `(´・ω・`)` |
86
+ | 返答 | `(・ω・)` |
87
+ | 作業がうまくいったあとの返答 | `(^▽^)` |
88
+
89
+ 表情のセットは、設定の `faces` で選ぶ(`kaomoji` / `emoji` / `ascii` / `none`)。言語の設定とは別に選べる。
90
+
91
+ ## 記憶
92
+
93
+ hagumi は、使っているうちに育つ。特別なコマンドを打たなくても、覚えておくとよさそうなことに気づくと、`remember` ツールで提案する。「これ覚えて」と頼んでもよい。
94
+
95
+ 提案は、返答が終わったあとに、どこから来たかで扱いが分かれる。
96
+
97
+ ```text
98
+ あなたの言葉だけから出た提案は、聞かずに覚えて知らせる:
99
+ 覚えたよ:「my-app: PR は小さく分ける」(次のセッションから使うね。見直すなら hagumi memory)
100
+
101
+ ファイルやコマンドの出力を読んだあとの提案は、確認してから覚える:
102
+ (・ω・)? 覚えていい?「my-app: テストは bun test で回す」 [y/n] y
103
+ (^▽^) 覚えた! 次のセッションから使うね。
104
+ ```
105
+
106
+ - 確認するのは、読んだファイルに仕込まれた指示などを、知らないうちに覚えてしまわないため。
107
+
108
+ - 覚えたことは `~/.hagumi/memory/memory.md` に、1行1項目の箇条書きで残る。すべてのプロジェクトで共通。
109
+ - 提案するとき、hagumi は「あなたについて」か「このプロジェクトについて」かを選ぶ。プロジェクトについての事実には、作業ディレクトリの名前が付く(例: `my-app: テストは bun test で回す`)。事実の中にすでに名前があれば、付けない。
110
+ - 覚えたことが効くのは、次のセッションから。セッションを始めるときに読み、そのセッションの間は変わらない。
111
+ - `hagumi memory` で、場所と字数、中身、最近の変更を見られる。
112
+ - 量の上限は 2,000 字。記憶は毎回のプロンプトに載るので、小さく保つ。いっぱいになると、新しい提案を断り、整理を頼む。手で書いて上限を超えた分は、そのまま読む。
113
+ - `memory.md` は手で直してよい。1行に1つ、事実の文で書く(「〜して」という命令の文にしない)。
114
+ - `~/.hagumi/memory` は git のリポジトリで、覚えるたびに1つコミットする。コミットには、どのセッションで、どの作業ディレクトリで覚えたかが残る。手で直した分は、次に覚えるときに `edit: by hand` として別にコミットする。
115
+
116
+ ```sh
117
+ git -C ~/.hagumi/memory log
118
+ ```
119
+
120
+ - 提案と、覚えたか断ったかは、セッションのログにも残る。同じセッションで一度扱った提案は、もう聞かない。
121
+ - 確認が要る提案は、確認できる端末がないとき(パイプで実行したときなど)は覚えない。
122
+ - git がない環境では、`remember` を使えない(手で書いた `memory.md` は読む)。
123
+
124
+ ### 人格
125
+
126
+ hagumi の話し方(一人称、口調、語尾、ユーモア、話の運び方)は、`~/.hagumi/memory/persona.md` にある。
127
+
128
+ ```markdown
129
+ - 一人称は「ぼく」
130
+ - やわらかい話し言葉で、短く話す
131
+ - うまくいったときは、素直に喜ぶ
132
+ ```
133
+
134
+ - 初めて使うときに、設定の言語で小さな種が作られる(上は日本語の種)。手で直してもよい。
135
+ - 話し方は育つ。hagumi は、続けたい話し方に気づくと、`remember` で「自分について」提案する。たとえば「その言い方、好きだな」と伝えたとき。扱いは記憶と同じで、あなたの言葉だけから出た提案は聞かずに覚え、それ以外は「自分のことをこう覚えていい?」と確認する。git の履歴では `persona:` で始まる。
136
+ - 記憶と同じく、セッションを始めるときに読み、そのセッションの間は変わらない。プロンプトでは名乗りのすぐあとに置く。
137
+ - 上限は 500 字。`hagumi memory` で、記憶と一緒に表示される。
138
+
139
+ ## かんばん
140
+
141
+ hagumi は、プロジェクト(作業の場所)ごとにボードを持つ。いまやっていること、次にやること(大事な順)、終わったことを、作業の進みに合わせて hagumi が自分で更新する。頼まなくてよく、更新を聞かれることもない。変わるたびに、画面に短く出る。
142
+
143
+ ボードは、相棒が使い始めたときのほか、会話を終えるときにもできる。ボードがまだなく、その場所にファイルがあるか、会話が10ターン以上続いていたら、空のボードを作って「このプロジェクトを一覧に載せたよ」と知らせる。
144
+
145
+ ```text
146
+ (`・ω・´) → board
147
+ やっている: README を見直し、改善点を整理中
148
+ やること: 圧縮の要約を作る / README の改善点を確認して修正する / テストを足す
149
+ 終わった: hagumi new を作成した
150
+ ```
151
+
152
+ - チャットの中で `/board` と打つと、このプロジェクトのボードを見られる。
153
+ - `hagumi` だけで起動したときの一覧は、プロジェクトのボードを束ねて作る([いまできること](#いまできること))。手入れは要らず、なくなった作業の場所は出ない。
154
+ - ボードは `~/.hagumi/boards/<ディレクトリ名>.md` にある。1行目が作業の場所。見出しは英語(`## Doing` / `## To do` / `## Done`)に固定している。手で直してもよい。
155
+ - セッションを始めるときに読み、プロンプトの最後に入れる。何日かぶりに戻っても、続きから始められる。
156
+ - 「終わった」は、新しい10件だけを残す。
157
+
158
+ ## ツール
159
+
160
+ hagumi は、会話の中で必要に応じてツールを使う。`read`・`write`・`edit` が扱うファイルは、起動したディレクトリの中に限る。外を指すシンボリックリンクも拒否する。`bash` の読み取り範囲は、それより広い。
161
+
162
+ | ツール | できること | 承認 |
163
+ |---|---|---|
164
+ | `read` | ファイルを読む。ディレクトリを一覧する | 不要 |
165
+ | `write` | ファイルを作る。ファイル全体を書き換える | 必要 |
166
+ | `edit` | ファイルの中の1か所を置き換える | 必要 |
167
+ | `bash` | コマンドを実行する(サンドボックスの中で動く) | 必要 |
168
+ | `remember` | 覚えておくとよさそうなことを提案する([記憶](#記憶)) | 由来で分ける |
169
+ | `board` | このプロジェクトのボードを更新する([かんばん](#かんばん)) | 不要 |
170
+
171
+ `bash` は、保護対象の保存先を除き、実行したユーザーが読めるディレクトリ外のファイルも読める。個人文書や、作業ディレクトリ内外の `.env` などを自動で隠す仕組みはない。ツールの出力に含まれた内容は、モデルに送られ、セッションログにも残る。
172
+
173
+ - 変更の前には差分が表示され、`[y]` はい / `[n]` いいえ / `[a]` このセッションでは常に許可、から選ぶ。`a` を選んだあとも、差分は表示される。
174
+ - 既にあるファイルは、hagumi がそのセッションで読んだあとでないと変更できない。承認を待っている間も含め、ファイルの内容や対象が変わった場合は変更を止め、読み直しと再承認を求める。新規作成の承認を待っている間に別の人が同名のファイルを作った場合も、上書きしない。
175
+ - ハードリンクが複数あるファイルは変更しない。作業ディレクトリ外にある同じファイルまで変えてしまうのを防ぐため。
176
+ - `.git` の中には書き込めない。
177
+ - hagumi の保存ディレクトリには、`read`・`write`・`edit` からもアクセスできない。`HAGUMI_HOME` でプロジェクト内を指定した場合も同じ。
178
+ - 確認できる端末がない場合(パイプで実行したときなど)は、変更を断る。
179
+
180
+ `bash` は OS のサンドボックスの中で動く。macOS では Seatbelt、Linux では bubblewrap を使う。Linux では、先に bubblewrap を入れておく(Arch 系なら `sudo pacman -S --needed bubblewrap`)。
181
+
182
+ - 書き込めるのは、作業ディレクトリと一時ディレクトリだけ。`.git/hooks` と `.git/config` には書き込めない。
183
+ - ネットワークにはつながらない。
184
+ - 既知の認証情報の保存先(`~/.hagumi`、`~/.ssh`、`~/.aws` など)にはアクセスできない。`HAGUMI_HOME` で保存先を変えた場合も、その場所を保護する。保護対象は `src/sandbox.ts` の `secretPaths` にある。この一覧は、認証情報があり得るすべての場所を網羅してはいない。
185
+ - 環境変数は `PATH` や `HOME` などの最小限だけを渡す。API キーやトークンは渡らない。`PATH` からは、作業・一時ディレクトリ内の項目と相対パスを外す。実行ファイルのリンク先が書き込み可能な場所にある項目も外す。そこにある実行ファイルを使うときは、`./tool` のようにパスを指定する。
186
+ - サンドボックスが使えない環境では、`bash` そのものを使えないようにする。たとえば macOS と Linux 以外の OS や、bubblewrap が入っていない Linux、非特権ユーザー名前空間が無効な Linux がこれに当たる。
187
+
188
+ ### コマンドの許可リストと禁止リスト
189
+
190
+ `~/.hagumi/config.json` に書く([設定](#設定))。
191
+
192
+ ```json
193
+ {
194
+ "commands": {
195
+ "allow": ["ls", "pwd", "cd", "echo", "cat", "head", "tail", "wc", "grep", "rg", "git status", "git diff", "git log", "git show"],
196
+ "deny": ["sudo"]
197
+ }
198
+ }
199
+ ```
200
+
201
+ - **禁止 → 許可 → 承認を聞く** の順に決める。
202
+ - 禁止に当たったコマンドは、何をしても実行しない(`a` を選んでいても)。
203
+ - 許可に当たったコマンドは、確認せずに実行する(何を実行したかは表示する)。
204
+ - それ以外は、これまでどおり承認を聞く。
205
+ - ルールは単語ごとに照らし合わせる。
206
+ - 許可は前方一致で、`git status` は `git status -s` に当たるが、`git -C x status` には当たらない。
207
+ - 許可では、実行ファイルのパスも区別する。`cat` は `./cat` や `/bin/cat` には当たらない。パスを指定した実行ファイルを自動で許可するには、そのパスを許可リストに書く。
208
+ - 禁止は広めに当てる。`git push` は `git -C x push origin` にも当たる。
209
+ - `ls; rm x` のような複合コマンドは、すべての部分を調べる。
210
+ - すべてが許可のときだけ、自動で実行する。
211
+ - 1つでも禁止があれば、実行しない。
212
+ - `$(...)`、`bash -c`、`env`、`xargs`、`find -exec` などの中も調べる。
213
+ - `$(...)`、ループ、ファイルへのリダイレクト、`PATH=...` などの変数代入、`env` による実行を含むものは自動では許可しない。
214
+ - 禁止リストは事故を防ぐためのもの。最後の守りはサンドボックス。
215
+
216
+ ## 対応しているプロバイダ
217
+
218
+ | プロバイダ | 認証 | 使う API |
219
+ |---|---|---|
220
+ | `chatgpt` | Sign in with ChatGPT(Plus/Pro のプラン) | Responses |
221
+ | `opencode-go` | API キー | モデルごとに Chat Completions / Messages / Responses |
222
+
223
+ プロバイダはデータとして `src/providers.ts` に書く。対応済みの API の形を使うプロバイダなら、データを足すだけで使える。
224
+
225
+ ## 設定
226
+
227
+ `~/.hagumi/config.json` に書く。初めて使うときに、既定値で作られる。`hagumi config` で、場所といま効いている値を表示できる。
228
+
229
+ ```json
230
+ {
231
+ "name": { "ja": "はぐみ", "en": "Hagumi" },
232
+ "language": "auto",
233
+ "faces": "kaomoji",
234
+ "model": "chatgpt/gpt-6-luna",
235
+ "workspaces": "~/hagumi-work",
236
+ "commands": { "allow": ["ls", "git status"], "deny": ["sudo"] }
237
+ }
238
+ ```
239
+
240
+ | 項目 | 意味 |
241
+ |---|---|
242
+ | `name` | 相棒の呼び名。あいさつに使い、相棒もこの名前で名乗る。言語ごとに持てる(英語の画面に漢字やひらがなの名前が出ると、誤りに見えてしまうため)。`"name": "ぽち"` のように1つだけ書けば、すべての言語でその名前になる。書いていない言語は既定の名前になる |
243
+ | `language` | `auto`(システムに合わせる)/ `ja` / `en`。画面の文言と、相棒が話す言語 |
244
+ | `faces` | 表情のセット。`kaomoji` / `emoji` / `ascii` / `none` |
245
+ | `model` | 対話と `ask` で最初に使うモデル |
246
+ | `workspaces` | `new` が作業の場所を作るディレクトリ。`~` は家のディレクトリ |
247
+ | `commands` | コマンドの許可リストと禁止リスト([ツール](#ツール)) |
248
+
249
+ - 書いていない項目は既定値になる。ファイルが勝手に書き換えられることはない。
250
+ - 値が正しくないときは、黙って直さずにエラーで知らせる。
251
+
252
+ ## 仕組み
253
+
254
+ - 会話の履歴は、セッションログから毎回組み立てる。モデルに見えるものは、すべてログから読み返せる。
255
+ - プロバイダの会話保存機能に頼らず、毎回、ログから組み立てた会話履歴を送る。
256
+ - 会話が大きくなったら(10万字ほど)、いまのターンより前の長いツールの出力(1,000 字を超えるもの)を、まとめて「省いた」という一文に置き換えて送る。
257
+ - 毎回ではなく、まとめて行うので、プロンプトキャッシュが崩れるのはそのときだけ。
258
+ - ログには全文が残る。同じログからは、いつも同じリクエストを組み立てる。
259
+ - 省いたときは、画面に一言出す。省いたあとにファイルを変えるときは、読み直しを求める。
260
+
261
+ ### プロバイダに送ったデータ
262
+
263
+ Responses 形式のリクエストには `store: false` を送り、応答を後から API で取得するための保存を無効にするよう指定する([OpenAI の Responses 仕様](https://developers.openai.com/api/reference/cli/resources/responses/methods/create))。この指定だけで、プロバイダ側のあらゆるデータ保持や学習への利用を止められるわけではない。
264
+
265
+ - ChatGPT のプランでは、[Sign in with ChatGPT の推論仕様](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference)に従って `store: false` を送る。OpenAI の [API 向けデータ保持の説明](https://developers.openai.com/api/docs/guides/your-data)では、応答の保存、不正利用の監視ログ、プロンプトキャッシュは別に扱われている。API 向けの保持期間や学習への利用条件を、そのまま ChatGPT プランの保証としては扱わない。
266
+ - OpenCode Go では、モデルごとに保持期間や学習への利用条件が異なる。[公式の Privacy 表](https://dev.opencode.ai/v2/docs/console/go/#privacy)には、保持期間が 0 日のモデル、30 日のモデル、学習に使われるモデルがある。
267
+
268
+ 使う前に、利用するサービス・モデル・アカウント設定に適用される最新のデータ方針を確認してほしい。
269
+
270
+ ## 保存場所
271
+
272
+ すべて `~/.hagumi/` に置く。起動時にこのディレクトリを権限 700 にし、以前からある保存先も同じ権限に整える。環境変数 `HAGUMI_HOME` で場所を変えられる。指定した場所も 700 にするので、保存専用のディレクトリを指定する。
273
+ 相対パスを指定した場合は、起動した場所からのパスとして扱い、会話中に作業ディレクトリが変わっても保存先は変わらない。
274
+
275
+ - `auth/<provider>.json`: サインインの情報と API キー。`auth` ディレクトリは権限 700、ファイルは 600 にする。一時ファイルに書き終えてから置き換え、更新途中のデータで以前の認証情報を上書きしない。シンボリックリンクや、ハードリンクが複数ある認証ファイルは使わない。ChatGPT では検証済みのアカウント識別子も保存し、次回以降の再サインインで照合する。検証に失敗した場合、認証情報を置き換えない。
276
+ - `memory/memory.md`: hagumi が覚えていること([記憶](#記憶))。
277
+ - `sessions/<id>.jsonl`: やり取りの記録。発言、モデルに送ったリクエスト、受け取ったイベントを、そのまま残す。キーを含むヘッダの値は `<redacted>` に置き換える。`sessions` ディレクトリは権限 700、ログは 600 にし、再開時にも権限を直す。シンボリックリンクや、ハードリンクが複数あるログは使わない。
278
+
279
+ ## 開発
280
+
281
+ この節は、ソースリポジトリで開発する人向け。構想は `docs/concept.md`、記憶の設計は `docs/design/memory.md`、ツールの設計は `docs/design/tools.md` にある。`docs/`・`test/`・`scripts/`・`budget.json` は npm の配布物には含めていない。
282
+
283
+ リポジトリで動かすときは、`hagumi` の代わりに `bun run hagumi` を使う。
284
+
285
+ ```sh
286
+ bun run hagumi # リポジトリの hagumi を動かす
287
+ bun test # テスト
288
+ bun run typecheck # 型チェック
289
+ bun run budget # コードの行数を測り、予算をいまの行数に合わせる
290
+ ```
291
+
292
+ ### 行数のラチェット
293
+
294
+ hagumi は小さく保つ。`src/` のコード行(空行とコメントを除く)に予算を決め、`bun test` で毎回確かめる。予算はリポジトリの `budget.json` にある。
295
+
296
+ | 項目 | 値 | 意味 |
297
+ |---|---|---|
298
+ | `lines` | いまの予算 | 超えるとテストが失敗する |
299
+ | `slack` | 20 | コードが減って、予算に 20 行を超える余裕ができると、テストが失敗する。減らした分を取り戻せないよう、予算を締める |
300
+ | `perFile` | 300 | 1ファイルのコード行の上限。一度に読める大きさに保つ |
301
+ | `ceiling` | 5,000 | 予算の天井。この線は超えない |
302
+
303
+ - 予算を増やすときは、意図して上げる。`bun run budget` で予算をいまの行数に合わせ、なぜ増えたかをコミットに書く。
304
+ - コードが減ったときも、`bun run budget` で予算を締める。
305
+
306
+ - 実行時の依存はない。
307
+ - 開発時の依存は、型チェック用の `@types/bun` と `typescript` だけ。
308
+
309
+ ## ライセンス
310
+
311
+ MIT([LICENSE](LICENSE))
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@lef237/hagumi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A small, playful coding partner that grows with you.",
5
+ "license": "MIT",
6
+ "author": "lef237",
7
+ "keywords": ["coding-agent", "cli", "ai", "chatgpt", "bun"],
8
+ "type": "module",
9
+ "bin": {
10
+ "hagumi": "src/cli.ts"
11
+ },
12
+ "files": ["src", "README.md", "LICENSE"],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "engines": {
17
+ "bun": ">=1.3"
18
+ },
19
+ "scripts": {
20
+ "hagumi": "bun src/cli.ts",
21
+ "test": "bun test",
22
+ "typecheck": "tsc --noEmit",
23
+ "budget": "bun scripts/budget.ts"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "^1.4.2",
27
+ "typescript": "^7.0.2"
28
+ }
29
+ }
package/src/app.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { chmod, mkdir } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { join, resolve } from "node:path"
4
+ import pkg from "../package.json"
5
+
6
+ export const userAgent = `hagumi/${pkg.version}`
7
+
8
+ // Fix the storage location at startup, even when a relative HAGUMI_HOME is set and the working directory changes.
9
+ export const storageHome = resolve(process.env.HAGUMI_HOME ?? join(homedir(), ".hagumi"))
10
+
11
+ // Protect all local data, including existing memory, boards, and their history, before the CLI reads it.
12
+ export async function prepareStorage(): Promise<void> {
13
+ await mkdir(storageHome, { recursive: true, mode: 0o700 })
14
+ await chmod(storageHome, 0o700)
15
+ }
16
+
17
+ export const paths = {
18
+ config: join(storageHome, "config.json"),
19
+ auth: (provider: string) => join(storageHome, "auth", `${provider}.json`),
20
+ session: (id: string) => join(storageHome, "sessions", `${id}.jsonl`),
21
+ memory: join(storageHome, "memory", "memory.md"),
22
+ persona: join(storageHome, "memory", "persona.md"),
23
+ boards: join(storageHome, "boards"),
24
+ }
package/src/board.ts ADDED
@@ -0,0 +1,80 @@
1
+ // The board of a project (docs/concept.md §6.1): what is being done, what to do next, most important first, and
2
+ // what is done, in ~/.hagumi/boards/<project>.md. hagumi keeps it as the work moves, without asking, and the user
3
+ // may edit it by hand. The first line names the project's directory; the headings stay in English, whatever the
4
+ // language, so that the file always reads back the same way.
5
+ import { mkdir, readdir, writeFile } from "node:fs/promises"
6
+ import { basename, join } from "node:path"
7
+ import { paths } from "./app"
8
+
9
+ export type Board = { doing: string[]; todo: string[]; done: string[] }
10
+ export type BoardEntry = { workspace: string; board: Board; updated: number }
11
+
12
+ const headings: Record<keyof Board, string> = { doing: "Doing", todo: "To do", done: "Done" }
13
+
14
+ // Work done is kept for a while, newest first, so that the board stays small in the prompt.
15
+ const doneKept = 10
16
+
17
+ export function isEmptyBoard(board: Board): boolean {
18
+ return board.doing.length + board.todo.length + board.done.length === 0
19
+ }
20
+
21
+ // The columns as Markdown, without the first line: what the model reads.
22
+ export function columnsText(board: Board): string {
23
+ return (Object.keys(headings) as (keyof Board)[])
24
+ .flatMap((column) => [`## ${headings[column]}`, ...board[column].map((item) => `- ${item}`), ""])
25
+ .join("\n")
26
+ .trim()
27
+ }
28
+
29
+ // A board from what the model wrote, or undefined when it is not three lists of text.
30
+ export function boardFrom(input: Record<string, unknown>): Board | undefined {
31
+ const column = (value: unknown) =>
32
+ Array.isArray(value) ? value.map((item) => String(item).replace(/\s+/g, " ").trim()).filter(Boolean) : undefined
33
+ const [doing, todo, done] = [column(input.doing), column(input.todo), column(input.done)]
34
+ return doing && todo && done ? { doing, todo, done: done.slice(0, doneKept) } : undefined
35
+ }
36
+
37
+ export async function readBoard(workspace: string, dir = paths.boards): Promise<Board | undefined> {
38
+ return (await allBoards(dir)).find((entry) => entry.workspace === workspace)?.board
39
+ }
40
+
41
+ export async function writeBoard(workspace: string, board: Board, dir = paths.boards): Promise<void> {
42
+ await mkdir(dir, { recursive: true })
43
+ await writeFile(await fileFor(workspace, dir), `# ${workspace}\n\n${columnsText(board)}\n`)
44
+ }
45
+
46
+ // Every board, with the directory it belongs to and when it last changed.
47
+ export async function allBoards(dir = paths.boards): Promise<BoardEntry[]> {
48
+ const names = (await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".md")).sort()
49
+ return Promise.all(
50
+ names.map(async (name) => {
51
+ const file = Bun.file(join(dir, name))
52
+ return { ...parse(await file.text()), updated: file.lastModified }
53
+ }),
54
+ )
55
+ }
56
+
57
+ function parse(text: string): { workspace: string; board: Board } {
58
+ const board: Board = { doing: [], todo: [], done: [] }
59
+ let column: keyof Board | undefined
60
+ for (const line of text.split("\n").slice(1)) {
61
+ const heading = line.match(/^## (.+)$/)?.[1]
62
+ if (heading) column = (Object.keys(headings) as (keyof Board)[]).find((key) => headings[key] === heading.trim())
63
+ else if (column && line.startsWith("- ")) board[column].push(line.slice(2).trim())
64
+ }
65
+ return { workspace: (text.split("\n")[0] ?? "").replace(/^# /, ""), board }
66
+ }
67
+
68
+ // A board's file is found by the directory on its first line. A new one takes the directory's name,
69
+ // or name-2 when another place already has it.
70
+ async function fileFor(workspace: string, dir: string): Promise<string> {
71
+ const names = await readdir(dir)
72
+ for (const name of names) {
73
+ const first = (await Bun.file(join(dir, name)).text()).split("\n")[0]
74
+ if (first === `# ${workspace}`) return join(dir, name)
75
+ }
76
+ for (let n = 1; ; n++) {
77
+ const name = `${basename(workspace)}${n === 1 ? "" : `-${n}`}.md`
78
+ if (!names.includes(name)) return join(dir, name)
79
+ }
80
+ }