Compare commits

...

3 Commits

7 changed files with 547 additions and 50 deletions

View File

@ -1,4 +1,6 @@
- [ ] Agent Skills サポート → [tickets/agent-skills.md](tickets/agent-skills.md)
- [ ] Workflow / Skills
- [ ] Workflow 実装 → [tickets/workflow.md](tickets/workflow.md)
- [ ] Agent Skills を Workflow として ingest → [tickets/agent-skills.md](tickets/agent-skills.md)
- [ ] ツール設計
- [ ] Bash ツール (Permission 層と統合) → [tickets/bash-tool.md](tickets/bash-tool.md)
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
@ -10,6 +12,7 @@
- [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md)
- [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- [ ] 入力欄の単語単位カーソル移動・削除 → [tickets/tui-input-word-motion.md](tickets/tui-input-word-motion.md)
- [ ] サブミット入力
- [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md)
- [ ] セッションログの Segment 保持 → [tickets/session-log-segments.md](tickets/session-log-segments.md)

View File

@ -398,12 +398,21 @@ impl App {
pub fn delete_char_after(&mut self) {
self.input.delete_after();
}
pub fn delete_word_before(&mut self) {
self.input.delete_word_before();
}
pub fn move_cursor_left(&mut self) {
self.input.move_left();
}
pub fn move_cursor_right(&mut self) {
self.input.move_right();
}
pub fn move_cursor_word_left(&mut self) {
self.input.move_word_left();
}
pub fn move_cursor_word_right(&mut self) {
self.input.move_word_right();
}
pub fn move_cursor_home(&mut self) {
self.input.move_home();
}

View File

@ -38,6 +38,48 @@ pub enum Atom {
Paste(PasteRef),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AtomClass {
Word(WordKind),
Sep,
Paste,
}
/// Sub-classification of word atoms. A run of equal `WordKind` is one word;
/// a kind switch is a word boundary. Lets `Ctrl+Left/Right` step over
/// runs of hiragana/katakana/han/ASCII independently when they sit adjacent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WordKind {
Ascii,
Hiragana,
Katakana,
Han,
Other,
}
fn atom_class(atom: &Atom) -> AtomClass {
match atom {
Atom::Paste(_) => AtomClass::Paste,
Atom::Char(c) => char_class(*c),
}
}
fn char_class(c: char) -> AtomClass {
if c.is_ascii_alphanumeric() || c == '_' {
return AtomClass::Word(WordKind::Ascii);
}
let cp = c as u32;
match cp {
0x3040..=0x309F => AtomClass::Word(WordKind::Hiragana),
0x30A0..=0x30FF | 0x31F0..=0x31FF => AtomClass::Word(WordKind::Katakana),
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FFFF => {
AtomClass::Word(WordKind::Han)
}
_ if c.is_alphanumeric() => AtomClass::Word(WordKind::Other),
_ => AtomClass::Sep,
}
}
pub struct InputBuffer {
atoms: Vec<Atom>,
/// Insertion point in `0..=atoms.len()`.
@ -106,6 +148,15 @@ impl InputBuffer {
}
}
/// Delete one word backward — the same span [`move_word_left`] would
/// jump over.
pub fn delete_word_before(&mut self) {
let end = self.cursor;
self.move_word_left();
let start = self.cursor;
self.atoms.drain(start..end);
}
pub fn move_left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
@ -114,6 +165,38 @@ impl InputBuffer {
self.cursor = (self.cursor + 1).min(self.atoms.len());
}
/// Move backward by one word. Skips a run of separators, then a run of
/// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
pub fn move_word_left(&mut self) {
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
self.cursor -= 1;
}
if self.cursor == 0 {
return;
}
let kind = atom_class(&self.atoms[self.cursor - 1]);
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == kind {
self.cursor -= 1;
}
}
/// Move forward by one word. Mirror of [`move_word_left`].
pub fn move_word_right(&mut self) {
while self.cursor < self.atoms.len()
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
{
self.cursor += 1;
}
if self.cursor == self.atoms.len() {
return;
}
let kind = atom_class(&self.atoms[self.cursor]);
while self.cursor < self.atoms.len() && atom_class(&self.atoms[self.cursor]) == kind {
self.cursor += 1;
}
}
pub fn move_home(&mut self) {
while self.cursor > 0 {
if matches!(self.atoms[self.cursor - 1], Atom::Char('\n')) {
@ -489,3 +572,258 @@ mod submit_segments_tests {
assert!(matches!(segs[0], Segment::Paste { .. }));
}
}
#[cfg(test)]
mod word_motion_tests {
use super::*;
fn buf_from(text: &str) -> InputBuffer {
let mut buf = InputBuffer::new();
for c in text.chars() {
buf.insert_char(c);
}
buf
}
fn cursor(buf: &InputBuffer) -> usize {
buf.cursor
}
#[test]
fn empty_buffer_is_noop() {
let mut buf = InputBuffer::new();
buf.move_word_left();
assert_eq!(cursor(&buf), 0);
buf.move_word_right();
assert_eq!(cursor(&buf), 0);
}
#[test]
fn forward_from_start_lands_after_first_word() {
let mut buf = buf_from("foo bar baz");
buf.cursor = 0;
buf.move_word_right();
assert_eq!(cursor(&buf), 3); // after "foo"
buf.move_word_right();
assert_eq!(cursor(&buf), 7); // after "foo bar"
buf.move_word_right();
assert_eq!(cursor(&buf), 11); // after "foo bar baz"
buf.move_word_right();
assert_eq!(cursor(&buf), 11); // end stays put
}
#[test]
fn backward_from_end_lands_at_last_word_start() {
let mut buf = buf_from("foo bar baz");
buf.move_word_left();
assert_eq!(cursor(&buf), 8); // start of "baz"
buf.move_word_left();
assert_eq!(cursor(&buf), 4); // start of "bar"
buf.move_word_left();
assert_eq!(cursor(&buf), 0); // start of "foo"
buf.move_word_left();
assert_eq!(cursor(&buf), 0);
}
#[test]
fn skips_runs_of_separators() {
let mut buf = buf_from("a , b");
buf.cursor = 1; // just after "a"
buf.move_word_right();
assert_eq!(cursor(&buf), 7); // after "b"
buf.move_word_left();
assert_eq!(cursor(&buf), 6); // start of "b"
buf.move_word_left();
assert_eq!(cursor(&buf), 0); // start of "a"
}
#[test]
fn newline_is_a_separator() {
let mut buf = buf_from("foo\nbar");
buf.cursor = 0;
buf.move_word_right();
assert_eq!(cursor(&buf), 3);
buf.move_word_right();
assert_eq!(cursor(&buf), 7);
buf.move_word_left();
assert_eq!(cursor(&buf), 4);
buf.move_word_left();
assert_eq!(cursor(&buf), 0);
}
#[test]
fn paste_counts_as_one_word() {
let mut buf = InputBuffer::new();
for c in "foo ".chars() {
buf.insert_char(c);
}
buf.insert_paste("anything".into());
for c in " bar".chars() {
buf.insert_char(c);
}
// atoms: f o o ' ' [P] ' ' b a r → 9 atoms, paste at index 4
let end = 9;
buf.cursor = end;
buf.move_word_left();
assert_eq!(cursor(&buf), 6); // start of "bar"
buf.move_word_left();
assert_eq!(cursor(&buf), 4); // before paste
buf.move_word_left();
assert_eq!(cursor(&buf), 0); // start of "foo"
buf.cursor = 0;
buf.move_word_right();
assert_eq!(cursor(&buf), 3); // after "foo"
buf.move_word_right();
assert_eq!(cursor(&buf), 5); // after paste
buf.move_word_right();
assert_eq!(cursor(&buf), 9); // after "bar"
}
#[test]
fn underscore_is_a_word_char() {
let mut buf = buf_from("foo_bar baz");
buf.cursor = 0;
buf.move_word_right();
assert_eq!(cursor(&buf), 7); // "foo_bar" is one word
}
#[test]
fn hiragana_run_is_one_word() {
// "こんにちは" — 5 hiragana atoms, no separators.
let mut buf = buf_from("こんにちは");
buf.cursor = 0;
buf.move_word_right();
assert_eq!(cursor(&buf), 5);
buf.move_word_left();
assert_eq!(cursor(&buf), 0);
}
#[test]
fn script_switch_is_a_word_boundary() {
// 漢字 | ひらがな | ASCII
let mut buf = buf_from("日本語のtest");
buf.cursor = 0;
buf.move_word_right();
assert_eq!(cursor(&buf), 3); // after "日本語"
buf.move_word_right();
assert_eq!(cursor(&buf), 4); // after "の"
buf.move_word_right();
assert_eq!(cursor(&buf), 8); // after "test"
buf.move_word_left();
assert_eq!(cursor(&buf), 4); // start of "test"
buf.move_word_left();
assert_eq!(cursor(&buf), 3); // start of "の"
buf.move_word_left();
assert_eq!(cursor(&buf), 0); // start of "日本語"
}
#[test]
fn katakana_separates_from_ascii() {
let mut buf = buf_from("カタカナsecret");
buf.cursor = 0;
buf.move_word_right();
assert_eq!(cursor(&buf), 4); // after "カタカナ"
buf.move_word_right();
assert_eq!(cursor(&buf), 10); // after "secret"
buf.move_word_left();
assert_eq!(cursor(&buf), 4);
buf.move_word_left();
assert_eq!(cursor(&buf), 0);
}
/// Render atoms as a string for assertions; pastes become `<P>`.
fn as_text(buf: &InputBuffer) -> String {
let mut out = String::new();
for a in &buf.atoms {
match a {
Atom::Char(c) => out.push(*c),
Atom::Paste(_) => out.push_str("<P>"),
}
}
out
}
#[test]
fn delete_word_removes_trailing_word_at_end() {
let mut buf = buf_from("foo bar");
buf.delete_word_before();
assert_eq!(as_text(&buf), "foo ");
assert_eq!(cursor(&buf), 4);
}
#[test]
fn delete_word_removes_word_at_cursor() {
let mut buf = buf_from("foo bar");
buf.cursor = 3; // right after "foo"
buf.delete_word_before();
assert_eq!(as_text(&buf), " bar");
assert_eq!(cursor(&buf), 0);
}
#[test]
fn delete_word_swallows_trailing_separators() {
let mut buf = buf_from("foo ");
buf.delete_word_before();
assert_eq!(as_text(&buf), "");
assert_eq!(cursor(&buf), 0);
}
#[test]
fn delete_word_at_start_is_noop() {
let mut buf = buf_from("foo");
buf.cursor = 0;
buf.delete_word_before();
assert_eq!(as_text(&buf), "foo");
assert_eq!(cursor(&buf), 0);
}
#[test]
fn delete_word_respects_script_boundary() {
// 「日本語の」末尾から1回削除すると、ひらがな部分「の」だけ消える
let mut buf = buf_from("日本語の");
buf.delete_word_before();
assert_eq!(as_text(&buf), "日本語");
assert_eq!(cursor(&buf), 3);
buf.delete_word_before();
assert_eq!(as_text(&buf), "");
assert_eq!(cursor(&buf), 0);
}
#[test]
fn delete_word_treats_paste_as_one_unit() {
let mut buf = InputBuffer::new();
for c in "foo ".chars() {
buf.insert_char(c);
}
buf.insert_paste("anything".into());
for c in " bar".chars() {
buf.insert_char(c);
}
// atoms: f o o ' ' [P] ' ' b a r (cursor at end = 9)
buf.delete_word_before();
assert_eq!(as_text(&buf), "foo <P> ");
assert_eq!(cursor(&buf), 6);
// Next deletion: trailing space then the paste atom (kind=Paste)
buf.delete_word_before();
assert_eq!(as_text(&buf), "foo ");
assert_eq!(cursor(&buf), 4);
}
#[test]
fn japanese_punctuation_is_a_separator() {
// 「、」 (U+3001) and 「。」 (U+3002) are not word chars.
let mut buf = buf_from("読んだ、走った。");
buf.cursor = 0;
buf.move_word_right();
assert_eq!(cursor(&buf), 1); // after "読" (han run of 1)
buf.move_word_right();
assert_eq!(cursor(&buf), 3); // after "んだ" (hiragana run)
// "、" is sep — skipped, then han "走"
buf.move_word_right();
assert_eq!(cursor(&buf), 5); // after "走"
buf.move_word_right();
assert_eq!(cursor(&buf), 7); // after "った"
}
}

View File

@ -407,6 +407,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
None
}
KeyCode::Enter => app.submit_input(),
KeyCode::Backspace if ctrl => {
app.delete_word_before();
None
}
KeyCode::Backspace => {
app.delete_char_before();
None
@ -415,10 +419,18 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.delete_char_after();
None
}
KeyCode::Left if ctrl => {
app.move_cursor_word_left();
None
}
KeyCode::Left => {
app.move_cursor_left();
None
}
KeyCode::Right if ctrl => {
app.move_cursor_word_right();
None
}
KeyCode::Right => {
app.move_cursor_right();
None

View File

@ -1,10 +1,12 @@
# Agent Skills サポート
# Agent Skills を Workflow として ingest
## 背景
[agentskills.io](https://agentskills.io/) で公開されているオープン標準 "Agent Skills" は、エージェントに手続き的知識・ドメイン能力をフォルダ単位で供給する形式。Anthropic 発で Claude Code / Cursor / OpenCode / Gemini CLI / Goose / OpenHands など主要な coding agent 群が採用しつつあり、ユーザーが既に書いた skill を insomnia に持ち込める/他ツールと共有できる価値が明確になっている。
[agentskills.io](https://agentskills.io/) の "Agent Skills" は Anthropic 発のオープン標準で、Claude Code / Cursor / OpenCode / Gemini CLI / Goose / OpenHands など主要な coding agent 群が採用している。ユーザーが既に書いた skill を insomnia に持ち込めるようにすることで、他ツールと能力資産を共有できる。
`docs/ref/memory-systems.md` でも Hermes Agent の "Skill Library" が agentskills.io 互換として言及済みで、将来のセルフ生成メモリ経路 (procedural memory) の受け皿としてもこのフォーマットに合わせておく利がある。
insomnia 側の一級概念は **Workflow** (`/<slug>`、`tickets/workflow.md`)。SKILL.md 形式は Workflow と意味的にほぼ同型procedural な指示本体 + メタデータ + 付随リソース)であり、別経路として並列管理するより **Workflow にマップして ingest する**方が呼び出し UX (`/<slug>`) と内部経路を一本化できる。
`docs/plan/memory.md` が「`SKILL.md` 形式は採用しない」と書いているのは Knowledge (`#<slug>`) の表現形式としての話で、本チケットの「Workflow への ingest 経路」とは矛盾しない。
### Skill の骨格 (仕様要旨)
@ -20,79 +22,99 @@ SKILL.md frontmatter:
| フィールド | 必須 | 制約 |
|---|---|---|
| `name` | ○ | 1-64 chars, `[a-z0-9-]+`, 先頭末尾 `-` 不可, `--` 連続不可, **ディレクトリ名と一致** |
| `name` | ○ | 1-64 chars, `[a-z0-9-]+`, **ディレクトリ名と一致** |
| `description` | ○ | 1-1024 chars, 非空 |
| `license` | | 自由文 |
| `compatibility` | | 1-500 chars |
| `metadata` | | 任意の key-value map |
| `allowed-tools` | | experimental (空白区切りのツールリスト) |
| `allowed-tools` | | experimental |
仕様の肝は **progressive disclosure**:
## 前提チケット
1. メタデータ (`name` + `description`) — セッション開始時に**全 skill 分**をプロンプトに載せる (~100 tokens/skill)
2. 指示本体 (SKILL.md body) — skill が必要になった時点で agent 側が読みに行く
3. リソース (`scripts/` / `references/` / `assets/`) — さらに on-demand
## 既存機構との関係
insomnia のシステムプロンプトは現状、`instruction` (テンプレ本体) + scope summary + AGENTS.md (trailing section) の 3 要素構成。Skill はこの並びに**任意個のオプショナル能力資産**として追加する位置取り。instruction / AGENTS.md を置き換えない。
配置は既存の prefix addressing と同じ軸:
- `$XDG_CONFIG_HOME/insomnia/skills/<name>/SKILL.md`
- `<project>/.insomnia/skills/<name>/SKILL.md`
- `$insomnia/skills/` (ビルトイン) は不要になるまで作らない
- `tickets/workflow.md` — Workflow loader / `/<slug>` resolve / `auto_invoke` 注入の本実装。本チケットはその ingest 経路を増やすだけで、Workflow 側の意味論には手を入れない
## 方針
### MVP スコープ
### ロードソース
1. **Skill ディスカバリと検証**
- 上記 2 経路のサブディレクトリを走査して `SKILL.md` を読む
- frontmatter を仕様通り検証。name 不一致・必須欠落・制約違反は hard error (Pod 起動失敗)
- 未知フィールドは `tracing::warn!` して無視 (pod-factory と同方針)
- 重複 name は workspace 層優先で user 層を上書き + ユーザー向け notification 発行
- **`$user/skills/<name>/SKILL.md`** — `$XDG_CONFIG_HOME/insomnia/skills/` 配下。**デフォルトで有効**
- **`$workspace/skills/`** — **デフォルトで無効**。manifest の `[skills]` セクションで明示指定したパスのみ ingest する
2. **システムプロンプトへの注入 (progressive disclosure 準拠)**
- `SystemPromptContext``skills: Vec<SkillSummary>` を追加 (`name`, `description`, 絶対パス)
- ビルトイン `$insomnia/default.md` に skill 列挙ブロックを追加 (空なら一切出力しない)
- ユーザーテンプレートからも `{{ skills }}` で参照可能
- **本体は注入しない**。description の末尾に絶対パスを併記し、agent が Read ツールで取れるようにする
```toml
[skills]
directories = [".claude/skills", ".cursor/skills"]
```
3. **本体への agent アクセス**
- skill ディレクトリを `Scope``permission = read` で自動 allow
- `scripts/` の実行は将来 Bash ツール + Permission 層で成立するまで Read 経由での閲覧に留める
各パスは workspace root からの相対 or 絶対。manifest の base directory に対して resolve する(既存 path 解決と同方針。Claude Code / Cursor 等が既に書いている `.claude/skills/` `.cursor/skills/` をそのまま流用できることが目的。
- ビルトイン `$insomnia/skills/` は不要になるまで作らない(前ガイドラインのまま)
### SKILL → Workflow マッピング
| SKILL.md frontmatter | Workflow frontmatter | 備考 |
|---|---|---|
| `name` | (ファイル名 = slug として扱う) | `name` がディレクトリ名と一致することは仕様上の不変。slug としてはディレクトリ名を使用 |
| `description` | `description` | そのまま |
| — | `auto_invoke` | **`true` 固定**。agentskills の progressive disclosureメタデータ常時注入と整合 |
| — | `user_invocable` | **`true` 固定** |
| — | `requires` | **空配列**。SKILL 側に概念がない |
| `license` / `compatibility` / `metadata` | — | 保持はするが Workflow 実行には影響しない |
| `allowed-tools` | — | `permission-extension-point.md` が Permission 層を整備するまで `tracing::warn!` して無視 |
Workflow 本文には SKILL.md 本体frontmatter 直下の Markdownをそのまま使う。
### scripts / references / assets
skill ディレクトリ全体(`SKILL.md` 本体だけでなく `scripts/` `references/` `assets/`)を Pod の Scope に `permission = read` で自動 union する。`scripts/` の実行は Bash ツール + Permission 層(`permission-extension-point.md`)が整うまで Read 経由の閲覧に留める。
### 衝突解決
同一 slug が複数ソースから来た場合の優先順位:
1. `<workspace>/.insomnia/memory/workflow/<slug>.md`(内製 Workflow
2. workspace skillsmanifest 指定パス)
3. user skills`$user/skills/`
衝突時は上位を採用し、shadow した側について `Event::Notification` を発行する。「明示的に書かれた内製 Workflow が外部資産より強い」順に並べる。
### 検証
- frontmatter は SKILL 仕様通り検証。`name` ↔ ディレクトリ名一致、`description` 長さ、`name` の文字種制約を hard error
- 検証エラーは個別 skill 単位で skip + `tracing::warn!`。一個の壊れた SKILL が Pod 起動を止めない(内製 Workflow とは違う扱い: 外部資産は緩く受ける)
- 未知フィールドは無視
### 範囲外
- `allowed-tools` フィールドの実効化 — `permission-extension-point.md` が Permission 層を整備するまで受信して無視
- skill 専用の実行機構 — `scripts/` は Bash / Read で自然に扱う
- skill の自動生成 (Hermes 風 Skill Library) — memory 系チケットで別途。本チケットで作る ingest 経路を再利用する側
- `allowed-tools` の実効化 → `permission-extension-point.md` 待ち
- skill 専用の実行機構`scripts/` の自動実行)— Bash ツールと Permission 層で自然に扱う
- skill の自動生成Hermes 風 Skill Library— memory 系チケットで別途、本チケットの ingest 経路を再利用する側
- ビルトイン skill (`$insomnia/skills/`) — 必要になるまで追加しない
- skill 間依存の解決 — 独立単位、相互参照は本体の Markdown リンクで
## 完了条件
- `$user/skills/``$workspace/skills/` の両方から skill をロードし、frontmatter 違反は Pod 起動エラーになる
- ビルトインの `$insomnia/default.md` をレンダした結果、存在する skill の name + description + 絶対パスがシステムプロンプトに列挙される
- skill が 0 件のとき、既存の出力 (AGENTS.md / scope summary) が全く変わらない
- skill ディレクトリが scope の readable に含まれ、agent が Read ツールで `SKILL.md` 本体・`scripts/`・`references/` にアクセスできる
- 単体テストで frontmatter 検証の正常 / 異常系、progressive disclosure レンダリング、user/workspace 重複解決が verify される
- `$user/skills/` 配下の SKILL.md が Workflow として登録され、`/<name>` で呼び出せる
- manifest で `[skills] directories = [...]` を指定した workspace では、そのパス配下の SKILL.md だけが追加で ingest される。指定しない workspace では workspace 側 skill は 0 件
- 内製 Workflow と同 slug の skill は内製優先で shadow され、Notification が発行される
- skill ディレクトリSKILL.md 本体・`scripts/`・`references/`・`assets/`)が scope readable に含まれ、agent が Read ツールでアクセスできる
- frontmatter 違反の skill は warn でスキップされ、他の skill / Pod 起動は影響を受けない
- 単体テストで frontmatter 検証、Workflow へのマッピング、衝突解決(内製 > workspace > user、manifest 未指定時の workspace skip が verify される
## 実装順序
1. `manifest` または新設 `skill` クレートに `Skill` 構造体と `SkillDirectoryLoader` を置く。SKILL.md パースと検証のみでテスト完結
2. `pod::SystemPromptContext``skills` を生やし、`ensure_system_prompt_materialized` で loader を呼ぶ
3. `resources/prompts/default.md` に skills ブロックを追加、`Pod::from_manifest` で skill ディレクトリを scope readable に union
4. 重複 name 解決と notification 発行を乗せる
1. SKILL.md パーサと frontmatter 検証を実装。Workflow frontmatter への変換器を含めてテスト完結
2. `$user/skills/` の loader を Workflow registry に接続
3. manifest に `[skills] directories: Vec<PathBuf>` を追加し、workspace 側 ingest を実装
4. 衝突解決と Notification 発行を乗せる
5. skill ディレクトリの Scope unionread 自動 allow
各ステップ終了時点でビルド通過・既存テスト合格を維持する。skill 0 件時の後方互換は Phase 2 で確認する。
各ステップ終了時点でビルド通過・既存テスト合格を維持する。
## 参照
- 仕様本体: https://agentskills.io/specification
- 採用状況: https://agentskills.io/home
- Anthropic 公式サンプル: https://github.com/anthropics/skills
- 検証 CLI: https://github.com/agentskills/agentskills/tree/main/skills-ref (将来 `pod skills validate` 相当を作るなら参考)
- 類似機構: `crates/pod/src/prompt_loader.rs` (prefix addressing), `crates/pod/src/agents_md.rs` (cwd-relative ingest), `crates/pod/src/system_prompt.rs` (render pipeline)
- 後続接続先: `docs/ref/memory-systems.md` の Skill Library (自動生成 skill の保存先)
- 検証 CLI: https://github.com/agentskills/agentskills/tree/main/skills-ref
- 前提: `tickets/workflow.md`
- 関連: `tickets/permission-extension-point.md``allowed-tools` 実効化の受け皿)、`docs/plan/workflow.md`、`docs/plan/memory.md`、`docs/ref/memory-systems.md` §Skill Library

View File

@ -0,0 +1,37 @@
# TUI: 入力欄の単語単位カーソル移動・削除
## 背景
TUI の入力欄では現在、`Left/Right` で1文字単位の移動、`Home/End` で行端への移動ができるが、単語単位で飛ぶ手段がない。`Backspace` も1文字ずつしか消せない。長めの行を編集するときに左右キーや Backspace を押し続けることになりテンポが悪い。
シェルやエディタで広く使われている `Ctrl+Left` / `Ctrl+Right` での単語単位移動と、`Ctrl+Backspace` での単語単位削除を提供したい。
## 要件
- `Ctrl+Left` で1単語ぶん後ろにカーソルが飛ぶ。
- `Ctrl+Right` で1単語ぶん前にカーソルが飛ぶ。
- 「単語」の境界は文字種ベースで判定する:
- **ASCII**: 英数字とアンダースコア (`_`)
- **ひらがな** (U+3040..U+309F)
- **カタカナ** (U+30A0..U+30FF, U+31F0..U+31FF)
- **漢字** (CJK Unified Ideographs: U+3400..U+4DBF, U+4E00..U+9FFF, U+F900..U+FAFF, U+20000..U+2FFFF)
- **その他の単語文字**: 上記に該当せず `char::is_alphanumeric()` が trueアクセント付きラテン、キリル、ハングル等をひとまとめ
- 上記以外(空白・句読点・改行)は区切り。
- 同じ種別の連続は1単語、種別が切り替わる位置で境界となる。形態素解析は使わない送り仮名の途中で切れることは許容、VSCode/emacs と同等の挙動)。
- ペースト atom (`Atom::Paste`) は不可分な1単語として扱うカーソルが内部に入らない既存の不変条件を維持する
- 既存の `Ctrl+Home/End` (履歴のスクロール)や `Ctrl+[` / `Ctrl+]` (ターンジャンプ)と衝突しないこと。
- 既存の `Left/Right`1文字移動`Backspace`1文字削除の挙動は変えない。
- `Ctrl+Backspace` でカーソルから1単語ぶん手前を削除する。境界判定は `Ctrl+Left` と同じ(同じロジックを共有)。
## 完了条件
- `crates/tui``Ctrl+Left` / `Ctrl+Right` が単語単位移動として動作する。
- `Ctrl+Backspace` で単語単位削除が動作する。
- 単語境界の判定にユニットテストが付いている(空・連続スペース・`\n` をまたぐ・Paste をまたぐ・ひらがな/カタカナ/漢字/ASCII の混在)。
- 既存テストが通る。
## 範囲外
- `Ctrl+Delete` / `Alt+d` などによる単語単位の前方削除(別チケット候補)。
- `Alt+Left/Right` など他の単語移動キーバインドの追加。
- 形態素解析による日本語の単語分割(辞書サイズ・起動コストの観点で TUI には過剰)。送り仮名や複合語の途中で切れる挙動は許容する。

76
tickets/workflow.md Normal file
View File

@ -0,0 +1,76 @@
# Workflow 実装
## 背景
`docs/plan/workflow.md` で決まった「制約付きの強制的な作業フロー」を `/<slug>` で呼び出せるようにする。Knowledge (`#<slug>`) を依存として inject できる経路を持つことで、procedural な能力を再利用可能な単位に固定する。
memory 機構(`docs/plan/memory.md`)からは独立してスタートできる: Workflow は人間が書く / consolidation の offer 経由でしか作られず、自動書き込み禁止のため Phase 2 / GC の前提に依存しない。Knowledge resolver は `requires` の inject 経路として相互依存する。
agent-skills (agentskills.io 形式) は本チケットの ingest 経路を再利用して Workflow として読み込む側になる(`tickets/agent-skills.md` 参照)。
## 決定事項の参照
詳細は `docs/plan/workflow.md` 参照。要点のみ:
- 呼び出し: `/<slug>`、フラットな名前空間、kebab-case
- 配置: `<workspace_root>/.insomnia/memory/workflow/<slug>.md`(ファイル名 = slug、frontmatter に `name` を持たない)
- frontmatter: `description` / `auto_invoke` (default OFF) / `user_invocable` (default ON) / `requires: [knowledge-slug, ...]`
- 実行: `requires` の Knowledge 本文を context に inject してから Workflow 本文を実行
- 自動書き込み禁止consolidation の write tool schema に `workflow` カテゴリを含めないことで構造的に担保。Linter で人間にも見える形で再保証)
## 方針
### MVP スコープ
1. **Workflow loader / 検証**
- `<workspace_root>/.insomnia/memory/workflow/*.md` を走査
- frontmatter を仕様通り検証。必須欠落・型不一致・slug とファイル名の不一致は hard errorPod 起動失敗)
- 未知フィールドは `tracing::warn!` して無視(既存 manifest と同方針)
- 重複 slug は最初に見つかったものを採用 + warn後述の skill ingest が乗ると衝突解決ルールが追加で必要になる)
2. **`/<slug>` 呼び出し経路**
- `Segment::WorkflowInvoke { slug }` を Pod 側で resolve
- 解決失敗slug 未登録 / `user_invocable: false`)は `ToolError` 相当でユーザーに返し、Worker には届かない
- `requires` の Knowledge 本文を Knowledge 検索ツールの slug 完全一致経路で取得し、Workflow 本文の前に context へ inject
- Workflow 本文は Markdown のままサブミット内容として扱うDSL 化はしない)
3. **`auto_invoke` 注入**
- `auto_invoke: true` な Workflow の `description` を通常 Pod の system prompt に常駐注入する。Phase 2 prompt には入れない
- 予算は Knowledge の常駐注入(`memory.md` §retrieval 経路と合算管理。description 上限は agentskills 準拠の 1024 chars に揃える
4. **Linter ルール**
- `memory/workflow/*.md` への write/edit は memory 専用 Tool 経由でのみ許可(汎用 Write/Edit は Scope deny
- consolidation の write tool schema からは `workflow` カテゴリを除外(自動書き込み禁止の構造的担保)
- Workflow 自体の Linter は frontmatter 検証 + slug/ファイル名一致のみ。意味検証は将来検討
### 範囲外
- DSL 化や step 粒度の制約Markdown 本文をそのまま実行)
- 中断・再開・トランザクション管理
- 品質検証フローmizchi empirical-prompt-tuning 相当、`docs/plan/workflow.md` §将来検討)
- LLM による Workflow 自律生成offer までで留める方針は本チケットでは扱わず、consolidation 側の責務)
- Knowledge 検索ツール本体の実装memory チケット側)。本チケットは slug 完全一致経路の利用者に留まる
## 完了条件
- `<workspace_root>/.insomnia/memory/workflow/*.md` をロードし、frontmatter 違反は Pod 起動エラーになる
- `/<slug>` を含む submit が `Segment::WorkflowInvoke` として送られ、Pod 側で `requires` Knowledge を inject した上で本文が実行される
- `auto_invoke: true` の Workflow description が通常 Pod の system prompt に列挙される
- `user_invocable: false` の Workflow は `/<slug>` 補完候補から除外され、明示呼び出しもエラーになる
- 単体テストで frontmatter 検証の正常 / 異常系、`requires` 解決、フラグ別の挙動が verify される
## 実装順序
1. `manifest` または既存 memory クレートに `Workflow` 構造体と `WorkflowDirectoryLoader` を置く。frontmatter パースと検証のみでテスト完結
2. Pod に Workflow registry を持たせ、`auto_invoke` description の system prompt 注入を組む
3. `Segment::WorkflowInvoke` の resolver を Pod 側に実装。Knowledge 検索ツールの slug 完全一致経路で `requires` を inject
4. 汎用 Write/Edit に対する `memory/workflow/` deny を Scope に追加、Linter 仕上げ
各ステップ終了時点でビルド通過・既存テスト合格を維持する。
## 参照
- 設計: `docs/plan/workflow.md`
- Knowledge / `#<slug>` の retrieval: `docs/plan/memory.md` §retrieval 経路
- Submit segment: `tickets/submit-tui-completion.md``Atom::WorkflowInvoke`)、`tickets/session-log-segments.md`
- 後続: `tickets/agent-skills.md`(外部 SKILL を Workflow として ingest する経路)