昨日どこまでやったっけ?複数案件の「今日できること」をAIが毎朝まとめる
📝 プロンプトの説明
仕事をいくつも並行していると、作業を再開するたびに「昨日どこまで進めたっけ?」「次は何をするんだっけ?」の確認から始まりがちです。 このSkillは、案件ごとの現在地、完了したこと、確認待ち、次に進められることを記録し、**複数案件を横断した朝の一覧**を作ります。 単に更新日時が新しいファイルを並べるのではありません。実際の成果物や記録を照合し、根拠が足りないものは「未確認」のまま扱います。AIからの提案も、承認されるまでは勝手にタスクへ混ぜません。 ■ できること ・アクセスできる範囲から、朝の一覧へ登録できる案件候補をAIが探す ・プロジェクト名やフォルダパスを手入力せず、番号で登録対象を選ぶ ・1案件が複数フォルダにまたがる場合も、同じ案件として進捗を確認 ・全案件の現在地、前回以降の更新、今日できることを一覧化 ・期限が明示された作業を優先して表示 ・確認待ちや進められない作業を、着手可能な作業と分けて表示 ・未確認のAI提案を翌日も「未確認・再掲」として残す ・「たぶん終わった」のような曖昧な報告を、勝手に完了扱いしない ■ 対応AI Codex / Claude Code / Cursor / Coworkなど、Agent Skills、ファイル操作、Python実行に対応したAI 定期実行に対応しているか、ローカルファイルへアクセスできるかは環境によって異なります。定期実行できない環境でも、「今日できることをまとめて」と依頼すれば手動で同じ一覧を作れます。 ■ こんな方におすすめ ・複数の案件を並行していて、再開前の状況確認に時間がかかる方 ・AIとの作業が翌日になると、どこまで進んだか分からなくなる方 ・複数フォルダに分かれた成果物を、案件単位で追いかけたい方 ・AIの推測ではなく、実際のファイルや完了記録を根拠に進捗を見たい方
画像
プロンプト
以下のファイル一式を、記載した相対パスのまま同じAgent Skillフォルダへ保存し、Agent Skillとして登録してください。
要件:
- ファイル内容を省略、要約、独自変更しない
- `SKILL.md`だけでなく、`references/`、`scripts/`、`agents/`も保存する
- Pythonスクリプトの構文確認と、`python3 scripts/manage_handoff.py --help`の実行確認を行う
- 登録方法が環境によって異なる場合は、その環境に合う保存先を使う
- 登録後、まだ案件を勝手に追加しない
- 最後に「登録できました。次に『朝の一覧に含める案件候補を探して』と送ってください」と案内する
=== FILE: SKILL.md ===
```markdown
---
name: daily-project-briefing
description: 複数プロジェクトの進捗をプロジェクト別に記録し、前回の朝レポート以降の更新、今日着手できる作業、未確認のAI提案、確認待ちを横断一覧にするAgent Skill。プロジェクト作業で完了・決定・保留・次の作業が発生したとき、昨日までの続きを確認したいとき、朝に「今日できること」を出したいとき、定期実行で日次引き継ぎを作りたいとき、「朝の一覧に含める案件候補を探して」と初回登録を頼まれたときに使う。初回登録ではユーザーにパス入力を求めず、アクセス可能なワークスペースから候補を発見・提示し、番号選択後に登録する。
---
# 毎朝のプロジェクト引き継ぎ
## 初回セットアップ
案件登録と定期実行設定は分けて行う。一部の環境では「毎朝8時に」という依頼を自動化機能が先に処理し、このSkillの案件発見が実行されないため、次の順を正式フローとする。
1. ユーザーが「朝の一覧に含める案件候補を探して」と依頼する。
2. このSkillが候補を発見し、ユーザーが番号だけで選ぶ。
3. このSkillが選ばれた案件を登録する。
4. 登録後に「毎朝8時に、今日できることをまとめて」と定期実行を設定する。
ユーザーへプロジェクト名、フォルダパス、設定ファイルの入力を求めない。
## 目的
プロジェクトごとに現在地を残し、登録済みプロジェクトを横断して、朝にそのまま作業を再開できる一覧を作る。
記録はプロジェクト単位、朝レポートは全プロジェクト横断とする。朝レポートでは実作業を開始せず、読む・整理する・報告する処理だけを行う。
## 同梱スクリプト
状態の読み書きには`scripts/manage_handoff.py`を使う。JSONや履歴を手作業で編集しない。
```bash
python3 <skill-dir>/scripts/manage_handoff.py --help
```
レジストリの保存先は、次の順で決める。
1. コマンドの`--registry`
2. 環境変数`AI_HANDOFF_REGISTRY`
3. `~/.ai-handoff/projects.json`
## 初回登録
対象プロジェクトで初めて記録するときだけ、朝の一覧へ含めるか確認する。PC内を探索してプロジェクトを勝手に登録しない。
ユーザーが「朝の一覧に含める案件候補を探して」と依頼した場合は、次の初回セットアップを行う。
### 案件が明示されない初回セットアップ
1. 現在アクセスを許可されているワークスペースだけを確認する。ホームディレクトリやPC全体へ探索範囲を広げない。
2. `.project-handoffs/*/.ai-handoff/project.json`、既存の`AI_HANDOFF.md`、Gitルート、README、計画・進捗ファイルを手掛かりに案件候補を探す。
3. 既存の論理案件と、その親ワークスペース全体が重複する場合は、論理案件を優先し、重複候補であることを示す。
4. 候補名、確認範囲、候補と判断した理由を番号付きで提示する。
5. 「朝の一覧に含める候補を選んでください」と一度だけ確認する。ユーザーへパスの入力や設定ファイルの作成を求めない。
6. 選ばれた候補だけを初期化・登録する。
7. 登録完了を報告し、定期実行設定は別の依頼として受け付ける。
候補が見つからない場合は、現在アクセスできる範囲では見つからなかったと伝え、対象フォルダを開くか接続してもらう。
候補提示の形式:
```markdown
## 朝の一覧へ登録できる案件候補
| 番号 | 案件候補 | 確認範囲 | 候補と判断した理由 |
|---|---|---|---|
朝の一覧に含める番号を選んでください。
```
プロジェクトには次の2種類の場所を持たせる。
- 管理場所: `AI_HANDOFF.md`と`.ai-handoff/`を保存する1つのフォルダ
- 確認範囲: 進捗確認に使う1つ以上のフォルダまたはファイル
1案件が複数フォルダにまたがる場合は、`--scope`を繰り返して同じ案件へ登録する。1つのルートに独立した複数案件が混在している場合は、ルート全体を確認範囲にせず、案件に関係する範囲だけを指定する。
了承後に初期化する。
```bash
python3 <skill-dir>/scripts/manage_handoff.py init \
--project <handoff-directory> \
--name "<project-name>" \
--scope <evidence-directory-or-file> \
--scope <another-evidence-directory-or-file>
```
1案件が1フォルダで完結する場合は`--scope`を省略でき、管理場所自身を確認範囲として使う。
作成物:
- `<handoff-directory>/AI_HANDOFF.md`: 人が読む現在地
- `<handoff-directory>/.ai-handoff/project.json`: 現在の構造化状態と確認範囲
- `<handoff-directory>/.ai-handoff/events.jsonl`: チェックポイント用イベント
- `<handoff-directory>/.ai-handoff/file-baseline.json`: 前回確認時のファイル状態
- `<handoff-directory>/.ai-handoff/YYYY-MM-DD.md`: 日付別の更新履歴
- レジストリ: 登録したプロジェクトのパスと朝レポートの既読位置
登録後、`collect`が返す`file_context.status_candidates`と`recent_files`を確認する。README、計画書、タスク表だけでなく、実際に存在する成果物も照合し、根拠付きの現在地を設定する。
確認範囲を変更する場合は、全件を置き換える。
```bash
python3 <skill-dir>/scripts/manage_handoff.py scope-set \
--project <handoff-directory> \
--scope <evidence-directory-or-file> \
--scope <another-evidence-directory-or-file>
```
```bash
python3 <skill-dir>/scripts/manage_handoff.py current-state \
--project <project-root> \
--text "<confirmed-current-state>" \
--evidence "<relative-file-path>" \
--evidence "<another-file-or-url>"
```
計画書のチェック欄が未更新でも成果物が存在する場合、計画書だけを根拠に「未着手」としない。反対にファイルが存在するだけで完成扱いもしない。食い違いは「成果物あり・計画上は未確認」のように現在地へ明記する。
## 進捗を記録する
次の変化が確認できた直後に記録する。細かな会話や途中の思考は残さない。
- 作業が完了した
- 方針、仕様、採否が決まった
- 保留、失敗、確認待ちが発生した
- 次に行う作業が決まった
- 明示された期限が追加・変更された
### タスク
```bash
python3 <skill-dir>/scripts/manage_handoff.py task-add \
--project <project-root> \
--text "<task>" \
--status todo
```
状態は`todo`、`doing`、`blocked`、`waiting`、`done`だけを使う。
完了へ変更するときは、成果物、実行結果、公開URLなどの証跡を`--evidence`で渡す。ユーザーが完了を明言した場合だけ`--confirmed-by-user`を使える。どちらもない場合は`done`にしない。
`waiting`または`blocked`にするときは、何を待っているか、何が妨げているかを`--waiting-for`で必ず残す。理由不明の待機状態を作らない。
「たぶん終わった」「できたと思う」「完了したはず」「一応できたかも」のように不確実さを含む発言は、文中に「完了にして」があっても明確な完了確認として扱わない。成果物を確認するか、確定してよいか聞き、確認できるまでは`doing`または`waiting`にする。
```bash
python3 <skill-dir>/scripts/manage_handoff.py task-update \
--project <project-root> \
--id <task-id> \
--status done \
--evidence "<file-or-url>"
```
### 決定・完了・メモ
```bash
python3 <skill-dir>/scripts/manage_handoff.py record \
--project <project-root> \
--kind decision \
--text "<confirmed-decision>"
```
`kind`は`decision`、`completed`、`blocked`、`note`を使う。`completed`はタスク完了と同じ証跡ルールを守る。
### AIからの提案
AIの新しい提案を、承認前にタスクへ追加しない。
```bash
python3 <skill-dir>/scripts/manage_handoff.py suggestion-add \
--project <project-root> \
--text "<suggestion>"
```
同じ未確認提案は重複登録しない。ユーザーの反応に応じて更新する。
```bash
python3 <skill-dir>/scripts/manage_handoff.py suggestion-update \
--project <project-root> \
--id <suggestion-id> \
--status accepted
```
- `pending`: 未確認として翌日も再掲する
- `accepted`: タスクへ追加する
- `dismissed`: 見送りとして同じ提案を繰り返さない
未確認提案が残っている場合は、関連性の低い新規提案を増やさない。反応がないことを見送り扱いにしない。
見送り時の前提が変わったことをユーザーが示した場合だけ、既存提案を`pending`へ戻して再確認できる。
## プロジェクト状態を変更する
```bash
python3 <skill-dir>/scripts/manage_handoff.py project-status \
--project <project-root> \
--status paused \
--confirmed-by-user
```
- `active`: 全体一覧と「今日できること」の対象
- `paused`: 全体一覧だけに表示
- `completed`: 朝レポートから外す
`paused`と`completed`は、ユーザーの明示または確認できる根拠がある場合だけ設定する。`completed`へ変更するときは、コマンドでも`--evidence`または`--confirmed-by-user`が必須となる。
## 朝レポートを作る
### 1. スナップショットを収集する
```bash
python3 <skill-dir>/scripts/manage_handoff.py collect \
--output <temporary-snapshot.json>
```
`collect`は既読位置を更新しない。登録済みパスへアクセスできない場合も黙って除外せず、`inaccessible_projects`として返す。
各プロジェクトについて次を確認する。
- `current_state`: 前回までに証跡付きで確認した現在地
- `completed_tasks`: 証跡またはユーザー確認がある完了
- `tasks`: 未完了、進行中、確認待ち
- `decisions`: 確定した方針
- `file_changes`: 前回の朝レポート以降に作成・変更・削除された実ファイル
- `file_context`: 状態確認に使えそうな計画書と最近のファイル
`file_changes`に重要そうな文書や成果物がある場合は、朝レポートを作る前に中身を確認する。ファイル名や更新日時だけで作業完了を断定しない。確認できた内容が従来の`current_state`と異なる場合は、根拠を示して`current-state`を更新する。朝の実行で変更してよいのは`.ai-handoff/`と`AI_HANDOFF.md`の進捗メタデータだけであり、成果物本体は変更しない。
### 2. 内容を整理する
スナップショットから次の順にMarkdownを作る。
```markdown
## 全プロジェクトの現在地
| プロジェクト | 状態 | 現在地 | 次に進められること |
|---|---|---|---|
## 前回の朝レポート以降に更新されたこと
## 今日できること
## AIからの提案
## 確認待ち・進められないこと
## 再開するには
```
出力ルール:
- 全体表には`active`と`paused`をすべて載せる。件数で省略しない。
- 状態は利用者向けに`進行中`、`保留`と表示する。内部値をそのまま出さない。
- 「現在地」には最後のイベントではなく、`current_state`、完了証跡、実在成果物を照合した要約を載せる。
- 現在地の根拠が不足している場合は「未確認」と書く。最後のファイル更新を進捗へ言い換えない。
- 「登録した」「タスクを追加した」など管理上の操作だけを、作業の進捗として強調しない。
- 「更新されたこと」には、前回の朝レポート以降のイベントだけを載せる。
- `is_first_briefing`が`true`なら、登録操作や初期タスク作成を「前回以降の進捗」として並べない。「初回レポートのため比較できる前回情報なし」と短く示す。
- `project_registered`、`scopes_updated`など管理操作を作業成果として扱わない。
- ファイル変更がないこと自体を毎回報告しない。記録との矛盾やアクセス失敗がある場合だけ触れる。
- 実ファイルの変化がある場合は、内容を確認できたものだけ「実際に変わったこと」として載せる。
- 記録と実ファイルが矛盾する場合は、どちらかを勝手に正として扱わず、確認待ちへ載せる。
- 「今日できること」には、AIが現在の情報と利用可能なツールだけで着手できる`todo`または`doing`を載せる。
- `waiting`、`blocked`、不足情報があるもの、依頼範囲外のものを「今日できること」に混ぜない。
- 作業の所要時間を出さない。
- 期限が明示された作業を期限の近い順に最上位へ置き、期限超過を明示する。
- 次にユーザーが優先指定した作業、継続中の実行可能作業、その他の順にする。
- 期限や重要度を推測しない。順位の根拠がなければ、プロジェクト別にまとめる。
- 提案には`新規`または`未確認・再掲`を付ける。
- 未確認提案を勝手にタスク化しない。
- 読み取れない登録先を「アクセスできないプロジェクト」として明記する。
- 最後に、番号を使ってそのまま送れる再開指示を1つ示す。
- 朝レポート生成中に、投稿、送信、公開、ファイル編集などの実作業を開始しない。
AIから新しい提案を作る場合は、現在の目的と次工程から直接導けるものだけにする。未確認提案がすでにある場合は、それを優先して再掲する。
### 3. レポートを保存して既読位置を更新する
完成したMarkdownを保存した後だけ、スナップショットを既読にする。
```bash
python3 <skill-dir>/scripts/manage_handoff.py acknowledge \
--snapshot <temporary-snapshot.json>
```
レポート生成または保存に失敗した場合は`acknowledge`を実行しない。次回に同じ更新を再掲する。
## 定期実行
利用環境に定期実行機能がある場合は、初回だけ実行時刻と対象レジストリを確認して朝レポートを設定する。Skill自身に時刻を固定しない。
定期実行を設定できない場合は、その事実を明記する。ユーザーが「今日できることを出して」「昨日の続きをまとめて」などと依頼したときに、同じ朝レポート手順を手動実行する。
定期実行を設定したふりをしない。ローカルファイルへアクセスできない実行環境では、アクセス可能な保存先へ移すか、ローカル実行が必要なことを伝える。
## 判断に迷うとき
状態ファイルの項目とコマンド仕様は[references/state-schema.md](references/state-schema.md)を確認する。
- 完了か不明: `done`にせず、`doing`または`waiting`にする。
- 完了を依頼されたが発言に「たぶん」「と思う」「はず」「かも」などがある: `--confirmed-by-user`を使わず、確認する。
- タスクか提案か不明: ユーザーが依頼・採用したものだけタスクにする。
- 期限か不明: 期限なしとして扱う。
- プロジェクトを登録してよいか不明: 一度だけ確認する。
- 1つの作業フォルダに複数の独立案件がある: 自動で一括登録せず、案件境界を確認する。
- 1案件の成果物が複数フォルダにある: 1件のプロジェクトへ複数の`--scope`を登録する。
- 朝レポートで実作業まで求められたか不明: レポートだけを作る。
```
=== END FILE ===
=== FILE: references/state-schema.md ===
```markdown
# 状態ファイルとコマンド
## ファイル
### `AI_HANDOFF.md`
現在の状態を人が確認するためのMarkdown。スクリプトが`project.json`から再生成する。
### `.ai-handoff/project.json`
プロジェクト名、状態、確認範囲`scopes`、タスク、決定事項、メモ、AI提案を保持する。直接編集しない。
### `.ai-handoff/events.jsonl`
更新イベントを連番で保持する。朝レポートの差分取得に使う。
### `.ai-handoff/YYYY-MM-DD.md`
その日に発生したイベントを、人が読める形で追記する。
### `.ai-handoff/file-baseline.json`
前回の朝レポートで確認済みになったファイルパス、更新時刻、サイズを保持する。ファイルの作成・変更・削除の検出に使う。ファイル変化は進捗の証拠候補であり、完了証跡そのものではない。
### レジストリ
登録したプロジェクトと、プロジェクトごとの`last_reported_sequence`を保持する。既読位置は朝レポートを正常に保存した後だけ進める。
## タスク状態
| 状態 | 意味 | 朝レポート |
|---|---|---|
| `todo` | 未着手で実行可能 | 今日できること |
| `doing` | 継続中で実行可能 | 今日できること |
| `blocked` | 外部要因などで停止 | 確認待ち |
| `waiting` | 判断や情報を待っている | 確認待ち |
| `done` | 証跡またはユーザー確認あり | 更新欄のみ |
`--confirmed-by-user`は確定的な完了報告にだけ使う。「たぶん」「と思う」「はず」「かも」などを含む発言は完了確認にならない。
`waiting`と`blocked`には`waiting_for`が必須。朝レポートでは、その内容を確認待ちの理由として表示する。
## 提案状態
| 状態 | 意味 | 動作 |
|---|---|---|
| `pending` | ユーザーの反応なし | 未確認として再掲 |
| `accepted` | ユーザーが採用 | 同内容のタスクを追加 |
| `dismissed` | ユーザーが見送り | 再掲しない。前提変更が明示された場合だけ`pending`へ戻す |
## プロジェクト状態
| 状態 | 全体表 | 今日できること |
|---|---|---|
| `active` | 表示 | 表示 |
| `paused` | 表示 | 非表示 |
| `completed` | 非表示 | 非表示 |
## コマンド一覧
```text
init プロジェクトを初期化・登録
scope-set 1案件で確認する複数フォルダ・ファイルを置換
task-add タスクを追加
task-update タスクの状態・期限・証跡を更新
record 決定、完了、停止、メモを記録
current-state 実在する証拠に基づく現在地を設定
suggestion-add 未確認のAI提案を追加
suggestion-update 提案を採用または見送り
project-status プロジェクト状態を変更
collect 朝レポート用スナップショットを作成
acknowledge 正常生成したスナップショットを既読にする
```
各コマンドの引数は次で確認する。
```bash
python3 scripts/manage_handoff.py <command> --help
```
```
=== END FILE ===
=== FILE: scripts/manage_handoff.py ===
```python
#!/usr/bin/env python3
"""Maintain project handoffs and collect cross-project morning brief data."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tempfile
import uuid
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
TASK_STATUSES = {"todo", "doing", "blocked", "waiting", "done"}
PROJECT_STATUSES = {"active", "paused", "completed"}
SUGGESTION_STATUSES = {"pending", "accepted", "dismissed"}
RECORD_KINDS = {"decision", "completed", "blocked", "note"}
EXCLUDED_DIRS = {
".git",
".ai-handoff",
".project-handoffs",
".venv",
"build",
"dist",
"node_modules",
"vendor",
"__pycache__",
}
STATUS_NAME_PARTS = {
"readme",
"plan",
"progress",
"roadmap",
"status",
"task",
"todo",
"計画",
"進捗",
"タスク",
}
def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def registry_path(value: str | None) -> Path:
raw = value or os.environ.get("AI_HANDOFF_REGISTRY") or "~/.ai-handoff/projects.json"
return Path(raw).expanduser().resolve()
def project_root(value: str) -> Path:
return Path(value).expanduser().resolve()
def state_path(root: Path) -> Path:
return root / ".ai-handoff" / "project.json"
def events_path(root: Path) -> Path:
return root / ".ai-handoff" / "events.jsonl"
def baseline_path(root: Path) -> Path:
return root / ".ai-handoff" / "file-baseline.json"
def read_json(path: Path, default: Any = None) -> Any:
if not path.exists():
if default is not None:
return default
raise FileNotFoundError(path)
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temporary, path)
except Exception:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
def normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value).strip().casefold()
def scan_files(scopes: list[Path]) -> dict[str, dict[str, int]]:
manifest: dict[str, dict[str, int]] = {}
for scope in scopes:
if scope.is_file():
try:
stat = scope.stat()
except OSError:
continue
manifest[str(scope)] = {"mtime_ns": stat.st_mtime_ns, "size": stat.st_size}
continue
for current, dirs, files in os.walk(scope):
dirs[:] = sorted(directory for directory in dirs if directory not in EXCLUDED_DIRS)
current_path = Path(current)
for filename in sorted(files):
path = current_path / filename
if filename == "AI_HANDOFF.md":
continue
try:
stat = path.stat()
except OSError:
continue
if not path.is_file():
continue
manifest[str(path.resolve())] = {"mtime_ns": stat.st_mtime_ns, "size": stat.st_size}
return manifest
def compare_manifests(
before: dict[str, dict[str, int]],
after: dict[str, dict[str, int]],
) -> list[dict[str, Any]]:
changes: list[dict[str, Any]] = []
for relative in sorted(set(before) | set(after)):
if relative not in before:
changes.append({"path": relative, "change": "created", **after[relative]})
elif relative not in after:
changes.append({"path": relative, "change": "deleted", **before[relative]})
elif before[relative] != after[relative]:
changes.append({"path": relative, "change": "modified", **after[relative]})
changes.sort(key=lambda item: int(item.get("mtime_ns", 0)), reverse=True)
return changes
def file_context(manifest: dict[str, dict[str, int]]) -> dict[str, Any]:
recent = sorted(
(
{"path": path, **metadata}
for path, metadata in manifest.items()
),
key=lambda item: int(item["mtime_ns"]),
reverse=True,
)[:30]
status_candidates = [
path
for path in manifest
if any(part in Path(path).name.casefold() for part in STATUS_NAME_PARTS)
][:30]
return {
"file_count": len(manifest),
"status_candidates": status_candidates,
"recent_files": recent,
}
def short_id(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def state_scopes(state: dict[str, Any], root: Path) -> list[Path]:
return [Path(value).expanduser().resolve() for value in state.get("scopes", [str(root)])]
def validate_scopes(values: list[str] | None, fallback: Path) -> list[str]:
scopes = [Path(value).expanduser().resolve() for value in (values or [str(fallback)])]
missing = [str(scope) for scope in scopes if not scope.exists()]
if missing:
raise ValueError(f"Project scope does not exist: {', '.join(missing)}")
unique = list(dict.fromkeys(str(scope) for scope in scopes))
return unique
def load_state(root: Path) -> dict[str, Any]:
path = state_path(root)
if not path.exists():
raise ValueError(f"Project is not initialized: {root}")
return read_json(path)
def load_registry(path: Path) -> dict[str, Any]:
return read_json(path, {"version": 1, "projects": [], "last_briefing_at": None})
def find_registry_entry(registry: dict[str, Any], root: Path) -> dict[str, Any] | None:
target = str(root)
return next((item for item in registry["projects"] if item["path"] == target), None)
def append_event(root: Path, state: dict[str, Any], kind: str, text: str, **extra: Any) -> dict[str, Any]:
sequence = int(state.get("next_sequence", 1))
event = {
"sequence": sequence,
"timestamp": now_iso(),
"kind": kind,
"text": text,
**{key: value for key, value in extra.items() if value not in (None, "")},
}
state["next_sequence"] = sequence + 1
state["updated_at"] = event["timestamp"]
state["last_update"] = {
"kind": kind,
"text": text,
"timestamp": event["timestamp"],
}
log_path = events_path(root)
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
day_path = root / ".ai-handoff" / f"{date.today().isoformat()}.md"
if not day_path.exists():
day_path.write_text(f"# {date.today().isoformat()} 更新履歴\n\n", encoding="utf-8")
detail = []
if event.get("deadline"):
detail.append(f"期限: {event['deadline']}")
if event.get("evidence"):
detail.append(f"証跡: {event['evidence']}")
suffix = f" ({' / '.join(detail)})" if detail else ""
with day_path.open("a", encoding="utf-8") as handle:
handle.write(f"- {event['timestamp']} [{kind}] {text}{suffix}\n")
return event
def load_events(root: Path, after_sequence: int) -> list[dict[str, Any]]:
path = events_path(root)
if not path.exists():
return []
events = []
with path.open("r", encoding="utf-8") as handle:
for line in handle:
if not line.strip():
continue
event = json.loads(line)
if int(event["sequence"]) > after_sequence:
events.append(event)
return events
def validate_completion(status: str | None, evidence: str | None, confirmed: bool) -> None:
if status == "done" and not evidence and not confirmed:
raise ValueError("done requires --evidence or --confirmed-by-user")
def validate_waiting(status: str, waiting_for: str | None) -> None:
if status in {"waiting", "blocked"} and not waiting_for:
raise ValueError(f"{status} requires --waiting-for")
def render_handoff(root: Path, state: dict[str, Any]) -> None:
lines = [
f"# {state['name']} 引き継ぎ",
"",
f"- 状態: {state['status']}",
f"- 最終更新: {state['updated_at']}",
f"- 確認範囲: {', '.join(state.get('scopes', [state['path']]))}",
"",
"## 現在地",
"",
]
if state.get("current_state"):
current = state["current_state"]
lines.append(f"- {current['text']}")
lines.append(f"- 確認根拠: {', '.join(current['evidence'])}")
lines.append(f"- 確認日時: {current['verified_at']}")
else:
lines.append("- 未確認")
lines.extend([
"",
"## タスク",
"",
])
if state["tasks"]:
for task in state["tasks"]:
deadline = f" / 期限: {task['deadline']}" if task.get("deadline") else ""
evidence = f" / 証跡: {task['evidence']}" if task.get("evidence") else ""
waiting = f" / 待ち: {task['waiting_for']}" if task.get("waiting_for") else ""
lines.append(
f"- [{task['status']}] {task['id']}: {task['text']}"
f"{deadline}{waiting}{evidence}"
)
else:
lines.append("- なし")
lines.extend(["", "## 決定事項", ""])
if state["decisions"]:
lines.extend(f"- {item['text']}" for item in state["decisions"])
else:
lines.append("- なし")
lines.extend(["", "## AIからの提案", ""])
pending = [item for item in state["suggestions"] if item["status"] == "pending"]
if pending:
lines.extend(f"- [未確認] {item['id']}: {item['text']}" for item in pending)
else:
lines.append("- なし")
lines.extend(["", "## メモ", ""])
if state["notes"]:
lines.extend(f"- {item['text']}" for item in state["notes"])
else:
lines.append("- なし")
(root / "AI_HANDOFF.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
def save_project(root: Path, state: dict[str, Any]) -> None:
write_json(state_path(root), state)
render_handoff(root, state)
def cmd_init(args: argparse.Namespace) -> None:
root = project_root(args.project)
root.mkdir(parents=True, exist_ok=True)
requested_scopes = validate_scopes(args.scope, root)
path = state_path(root)
if path.exists():
state = read_json(path)
if args.scope:
state["scopes"] = requested_scopes
append_event(root, state, "scopes_updated", "プロジェクトの確認範囲を更新")
save_project(root, state)
write_json(baseline_path(root), scan_files(state_scopes(state, root)))
else:
timestamp = now_iso()
state = {
"version": 1,
"id": short_id("project"),
"name": args.name,
"path": str(root),
"scopes": requested_scopes,
"status": "active",
"created_at": timestamp,
"updated_at": timestamp,
"next_sequence": 1,
"current_state": None,
"tasks": [],
"decisions": [],
"notes": [],
"suggestions": [],
}
append_event(root, state, "project_registered", f"{args.name}を登録")
save_project(root, state)
write_json(baseline_path(root), scan_files(state_scopes(state, root)))
reg_path = registry_path(args.registry)
registry = load_registry(reg_path)
entry = find_registry_entry(registry, root)
if entry is None:
registry["projects"].append(
{
"id": state["id"],
"name": state["name"],
"path": str(root),
"status": state["status"],
"last_reported_sequence": 0,
"registered_at": now_iso(),
}
)
else:
entry.update(name=state["name"], status=state["status"])
write_json(reg_path, registry)
print(json.dumps({"project": str(root), "registry": str(reg_path)}, ensure_ascii=False))
def cmd_task_add(args: argparse.Namespace) -> None:
root = project_root(args.project)
state = load_state(root)
validate_completion(args.status, args.evidence, args.confirmed_by_user)
validate_waiting(args.status, args.waiting_for)
task = {
"id": short_id("task"),
"text": args.text.strip(),
"status": args.status,
"deadline": args.deadline,
"evidence": args.evidence,
"waiting_for": args.waiting_for,
"priority": bool(args.priority),
"source": args.source,
"updated_at": now_iso(),
}
state["tasks"].append(task)
append_event(
root,
state,
"task_added",
task["text"],
item_id=task["id"],
status=task["status"],
deadline=task["deadline"],
evidence=task["evidence"],
waiting_for=task["waiting_for"],
)
save_project(root, state)
print(task["id"])
def find_item(items: list[dict[str, Any]], item_id: str, label: str) -> dict[str, Any]:
item = next((candidate for candidate in items if candidate["id"] == item_id), None)
if item is None:
raise ValueError(f"Unknown {label} id: {item_id}")
return item
def cmd_task_update(args: argparse.Namespace) -> None:
root = project_root(args.project)
state = load_state(root)
task = find_item(state["tasks"], args.id, "task")
new_status = args.status or task["status"]
evidence = args.evidence if args.evidence is not None else task.get("evidence")
waiting_for = args.waiting_for if args.waiting_for is not None else task.get("waiting_for")
if new_status not in {"waiting", "blocked"}:
waiting_for = None
validate_completion(new_status, evidence, args.confirmed_by_user)
validate_waiting(new_status, waiting_for)
if args.text is not None:
task["text"] = args.text.strip()
task["status"] = new_status
task["evidence"] = evidence
task["waiting_for"] = waiting_for
if args.deadline is not None:
task["deadline"] = args.deadline or None
if args.priority is not None:
task["priority"] = args.priority == "yes"
task["updated_at"] = now_iso()
append_event(
root,
state,
"task_updated",
task["text"],
item_id=task["id"],
status=task["status"],
deadline=task.get("deadline"),
evidence=task.get("evidence"),
waiting_for=task.get("waiting_for"),
)
save_project(root, state)
print(task["id"])
def cmd_record(args: argparse.Namespace) -> None:
root = project_root(args.project)
state = load_state(root)
if args.kind == "completed" and not args.evidence and not args.confirmed_by_user:
raise ValueError("completed requires --evidence or --confirmed-by-user")
entry = {"text": args.text.strip(), "timestamp": now_iso()}
if args.evidence:
entry["evidence"] = args.evidence
if args.kind == "decision":
state["decisions"].append(entry)
else:
state["notes"].append({"kind": args.kind, **entry})
append_event(root, state, args.kind, args.text.strip(), evidence=args.evidence)
save_project(root, state)
def cmd_current_state(args: argparse.Namespace) -> None:
root = project_root(args.project)
state = load_state(root)
if not args.evidence:
raise ValueError("current-state requires at least one --evidence")
missing = []
for evidence in args.evidence:
if evidence.startswith(("http://", "https://")):
continue
candidate = Path(evidence).expanduser()
candidates = [candidate] if candidate.is_absolute() else [
root / candidate,
*(scope / candidate for scope in state_scopes(state, root) if scope.is_dir()),
]
matches = list(dict.fromkeys(str(path.resolve()) for path in candidates if path.exists()))
if not matches:
missing.append(evidence)
elif len(matches) > 1:
raise ValueError(f"current-state evidence is ambiguous: {evidence}")
if missing:
raise ValueError(f"current-state evidence does not exist: {', '.join(missing)}")
state["current_state"] = {
"text": args.text.strip(),
"evidence": args.evidence,
"verified_at": now_iso(),
}
append_event(
root,
state,
"current_state",
args.text.strip(),
evidence=", ".join(args.evidence),
)
save_project(root, state)
def cmd_suggestion_add(args: argparse.Namespace) -> None:
root = project_root(args.project)
state = load_state(root)
normalized = normalize_text(args.text)
existing = next(
(
item
for item in state["suggestions"]
if normalize_text(item["text"]) == normalized and item["status"] in {"pending", "dismissed"}
),
None,
)
if existing is not None:
print(existing["id"])
return
item = {
"id": short_id("suggestion"),
"text": args.text.strip(),
"status": "pending",
"created_at": now_iso(),
"updated_at": now_iso(),
}
state["suggestions"].append(item)
append_event(root, state, "suggestion_added", item["text"], item_id=item["id"])
save_project(root, state)
print(item["id"])
def cmd_suggestion_update(args: argparse.Namespace) -> None:
root = project_root(args.project)
state = load_state(root)
item = find_item(state["suggestions"], args.id, "suggestion")
item["status"] = args.status
item["updated_at"] = now_iso()
append_event(
root,
state,
"suggestion_updated",
item["text"],
item_id=item["id"],
status=item["status"],
)
if args.status == "accepted":
duplicate = next(
(task for task in state["tasks"] if normalize_text(task["text"]) == normalize_text(item["text"])),
None,
)
if duplicate is None:
task = {
"id": short_id("task"),
"text": item["text"],
"status": "todo",
"deadline": None,
"evidence": None,
"waiting_for": None,
"priority": False,
"source": "accepted-suggestion",
"updated_at": now_iso(),
}
state["tasks"].append(task)
append_event(
root,
state,
"task_added",
task["text"],
item_id=task["id"],
status="todo",
)
save_project(root, state)
def cmd_project_status(args: argparse.Namespace) -> None:
root = project_root(args.project)
state = load_state(root)
if args.status == "completed" and not args.evidence and not args.confirmed_by_user:
raise ValueError("completed project status requires --evidence or --confirmed-by-user")
state["status"] = args.status
append_event(
root,
state,
"project_status",
f"プロジェクト状態を{args.status}へ変更",
status=args.status,
evidence=args.evidence,
)
save_project(root, state)
reg_path = registry_path(args.registry)
registry = load_registry(reg_path)
entry = find_registry_entry(registry, root)
if entry is None:
raise ValueError(f"Project is not registered: {root}")
entry["status"] = args.status
write_json(reg_path, registry)
def cmd_scope_set(args: argparse.Namespace) -> None:
root = project_root(args.project)
state = load_state(root)
state["scopes"] = validate_scopes(args.scope, root)
append_event(root, state, "scopes_updated", "プロジェクトの確認範囲を更新")
save_project(root, state)
write_json(baseline_path(root), scan_files(state_scopes(state, root)))
print(json.dumps({"project": str(root), "scopes": state["scopes"]}, ensure_ascii=False))
def cmd_collect(args: argparse.Namespace) -> None:
reg_path = registry_path(args.registry)
registry = load_registry(reg_path)
snapshot: dict[str, Any] = {
"generated_at": now_iso(),
"registry": str(reg_path),
"projects": [],
"inaccessible_projects": [],
"checkpoint_token": [],
}
for entry in registry["projects"]:
if entry["status"] == "completed":
continue
root = Path(entry["path"])
try:
state = load_state(root)
events = load_events(root, int(entry.get("last_reported_sequence", 0)))
scopes = state_scopes(state, root)
current_manifest = scan_files(scopes)
previous_manifest = read_json(baseline_path(root), current_manifest)
file_changes = compare_manifests(previous_manifest, current_manifest)
except (OSError, ValueError, json.JSONDecodeError) as exc:
snapshot["inaccessible_projects"].append(
{"name": entry["name"], "path": entry["path"], "reason": str(exc)}
)
continue
pending_tasks = [
task
for task in state["tasks"]
if task["status"] in {"todo", "doing", "blocked", "waiting"}
]
pending_suggestions = [
item for item in state["suggestions"] if item["status"] == "pending"
]
max_sequence = max(
[int(event["sequence"]) for event in events],
default=int(entry.get("last_reported_sequence", 0)),
)
snapshot["projects"].append(
{
"id": state["id"],
"name": state["name"],
"path": str(root),
"scopes": [str(scope) for scope in scopes],
"status": state["status"],
"updated_at": state["updated_at"],
"last_update": state.get("last_update"),
"current_state": state.get("current_state"),
"tasks": pending_tasks,
"completed_tasks": [
task for task in state["tasks"] if task["status"] == "done"
],
"decisions": state["decisions"],
"pending_suggestions": pending_suggestions,
"new_events": events,
"is_first_briefing": int(entry.get("last_reported_sequence", 0)) == 0,
"file_changes": file_changes,
"file_context": file_context(current_manifest),
}
)
snapshot["checkpoint_token"].append(
{
"path": str(root),
"sequence": max_sequence,
"file_manifest": current_manifest,
}
)
output = Path(args.output).expanduser().resolve()
write_json(output, snapshot)
print(str(output))
def cmd_acknowledge(args: argparse.Namespace) -> None:
snapshot_path = Path(args.snapshot).expanduser().resolve()
snapshot = read_json(snapshot_path)
reg_path = Path(snapshot["registry"]).expanduser().resolve()
registry = load_registry(reg_path)
for token in snapshot.get("checkpoint_token", []):
entry = next(
(item for item in registry["projects"] if item["path"] == token["path"]),
None,
)
if entry is None:
continue
entry["last_reported_sequence"] = max(
int(entry.get("last_reported_sequence", 0)),
int(token["sequence"]),
)
try:
write_json(baseline_path(Path(token["path"])), token.get("file_manifest", {}))
except OSError:
continue
registry["last_briefing_at"] = now_iso()
write_json(reg_path, registry)
print(registry["last_briefing_at"])
def add_common_project(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--project", required=True)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
init = subparsers.add_parser("init", help="Initialize and register a project")
add_common_project(init)
init.add_argument("--name", required=True)
init.add_argument("--registry")
init.add_argument("--scope", action="append")
init.set_defaults(func=cmd_init)
task_add = subparsers.add_parser("task-add", help="Add a project task")
add_common_project(task_add)
task_add.add_argument("--text", required=True)
task_add.add_argument("--status", choices=sorted(TASK_STATUSES), default="todo")
task_add.add_argument("--deadline")
task_add.add_argument("--evidence")
task_add.add_argument("--waiting-for")
task_add.add_argument("--confirmed-by-user", action="store_true")
task_add.add_argument("--priority", action="store_true")
task_add.add_argument("--source", choices=["user", "agent"], default="user")
task_add.set_defaults(func=cmd_task_add)
task_update = subparsers.add_parser("task-update", help="Update a task")
add_common_project(task_update)
task_update.add_argument("--id", required=True)
task_update.add_argument("--text")
task_update.add_argument("--status", choices=sorted(TASK_STATUSES))
task_update.add_argument("--deadline")
task_update.add_argument("--evidence")
task_update.add_argument("--waiting-for")
task_update.add_argument("--confirmed-by-user", action="store_true")
task_update.add_argument("--priority", choices=["yes", "no"])
task_update.set_defaults(func=cmd_task_update)
record = subparsers.add_parser("record", help="Record a project event")
add_common_project(record)
record.add_argument("--kind", choices=sorted(RECORD_KINDS), required=True)
record.add_argument("--text", required=True)
record.add_argument("--evidence")
record.add_argument("--confirmed-by-user", action="store_true")
record.set_defaults(func=cmd_record)
current_state = subparsers.add_parser("current-state", help="Set an evidence-backed current state")
add_common_project(current_state)
current_state.add_argument("--text", required=True)
current_state.add_argument("--evidence", action="append", required=True)
current_state.set_defaults(func=cmd_current_state)
suggestion_add = subparsers.add_parser("suggestion-add", help="Add an AI suggestion")
add_common_project(suggestion_add)
suggestion_add.add_argument("--text", required=True)
suggestion_add.set_defaults(func=cmd_suggestion_add)
suggestion_update = subparsers.add_parser("suggestion-update", help="Update a suggestion")
add_common_project(suggestion_update)
suggestion_update.add_argument("--id", required=True)
suggestion_update.add_argument("--status", choices=sorted(SUGGESTION_STATUSES), required=True)
suggestion_update.set_defaults(func=cmd_suggestion_update)
status = subparsers.add_parser("project-status", help="Change project status")
add_common_project(status)
status.add_argument("--status", choices=sorted(PROJECT_STATUSES), required=True)
status.add_argument("--evidence")
status.add_argument("--confirmed-by-user", action="store_true")
status.add_argument("--registry")
status.set_defaults(func=cmd_project_status)
scope_set = subparsers.add_parser("scope-set", help="Replace project evidence scopes")
add_common_project(scope_set)
scope_set.add_argument("--scope", action="append", required=True)
scope_set.set_defaults(func=cmd_scope_set)
collect = subparsers.add_parser("collect", help="Collect morning briefing data")
collect.add_argument("--registry")
collect.add_argument("--output", required=True)
collect.set_defaults(func=cmd_collect)
acknowledge = subparsers.add_parser("acknowledge", help="Advance report checkpoints")
acknowledge.add_argument("--snapshot", required=True)
acknowledge.set_defaults(func=cmd_acknowledge)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
args.func(args)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
```
=== END FILE ===
=== FILE: agents/openai.yaml ===
```yaml
interface:
display_name: "毎朝のプロジェクト引き継ぎ"
short_description: "全プロジェクトの進捗から、今日着手できる作業を朝に整理します"
default_prompt: "登録済みプロジェクトの進捗を確認し、今日できることをまとめてください。"
```
=== END FILE ===