diff --git a/.agents/skills/decide/SKILL.md b/.agents/skills/decide/SKILL.md new file mode 100644 index 0000000..e36052d --- /dev/null +++ b/.agents/skills/decide/SKILL.md @@ -0,0 +1,87 @@ +--- +name: decide +description: 設計上の決定を記録する。メインドキュメントの更新・検討済み案の記録・journal作成・コミットを一度に行う。 +disable-model-invocation: true +argument-hint: [決定の要旨] +--- + +# 決定を記録する + +AGENTS.md のルール1〜4に従い、決定を4箇所に分散して記録する。 +どれか1つでも欠けると後から追えなくなるので、全部やる。 + +## 手順 + +### 0. 決定内容を確認する + +以下が揃っているか確認し、足りなければユーザーに聞く。推測で埋めない。 + +- 採用した案は何か +- それによって**今後効く制約**は何か(却下理由ではなく、現在の状態として書ける文) +- 落とした案があるか +- この決定が未確認の推測に依存していないか + +### 1. メインドキュメントを更新する + +該当する直下の `*.md` を更新する。 + +- 採用した案・現在の設定を書く。「なぜAなのか」は1〜3行まで。 +- 「なぜBではないのか」は書かない。代わりに**制約文**として書く。 + - ✗ `B案はKLEパーサーが壊れるので却下した` + - ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)` +- 比較表・検討過程・ベンチマーク結果をここに書かない。 + +### 2. 落とした案を1行残す + +メインドキュメント末尾の `## 検討済み・非採用` に追記する。 + +``` +- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md) +``` + +- 理由は書かない。journalファイル名だけ。 +- セクションが5行を超えたら、古いものから落とす。長くなるのは書きすぎのサイン。 + +### 3. journal を書く + +`journal/YYYY-MM-DD-.md` を作る。トピックを詰め込まない(1決定1ファイル)。 + +構成は上から順に: + +1. **結論** — 何を採用したか +2. **案の比較** — 簡潔な表。メリット/デメリットを1行ずつ +3. 検証ログ・根拠・捨てた案の詳細 + +冒頭20行で結論と選択肢が分かること。整形は最低限でよい。 +他ファイルへのリンクも、他ファイルからのリンクも張らない。 + +### 4. 推測を扱う + +この決定が未確認の推測に依存する場合: + +- メインドキュメント本文に `[推測:P-n]` を付ける +- 末尾の `## 未確認事項` に項目を足し、**何を確認できれば事実に格上げできるか**を書く + +逆に、この決定で検証が済んだ推測があれば、タグを外して `## 未確認事項` から削除する。 +本文タグと末尾項目は必ず対で操作する(片方だけ残さない)。 + +### 5. コミットする + +メインドキュメントと journal を**同じコミット**に入れる。1コミット1決定。 + +メッセージ: + +``` +<何を変えたか(1行)> + +<なぜそうしたか 数行> + +Journal: journal/2026-09-16-cluster-metadata.md +``` + +「なぜ」の詳細はコミットメッセージに書ききらない。journal 側にある。 + +## 注意 + +- 決定が固まっていない段階では使わない。調査中は journal だけ書けばよい。 +- 複数の決定をまとめてコミットしない。分ける。 diff --git a/.agents/skills/doc-lint/SKILL.md b/.agents/skills/doc-lint/SKILL.md new file mode 100644 index 0000000..543920f --- /dev/null +++ b/.agents/skills/doc-lint/SKILL.md @@ -0,0 +1,84 @@ +--- +name: doc-lint +description: ドキュメントがAGENTS.mdの運用ルール1〜5に沿っているか検査する。推測タグと未確認事項の対応漏れ、保留課題の書式・参照・必須欄、journalへのリンク、比較表の混入などを検出する。 +argument-hint: [ファイル...] +--- + +# ドキュメントのルール違反を検査する + +機械的に判定できる項目はスクリプトで検出し、判断が要る項目だけ目視で見る。 +**検出して報告するだけで、勝手に直さない。** 修正は内容を確認してもらってから。 + +## 1. スクリプトを走らせる + +``` +python .Codex/skills/doc-lint/doc-lint.py [ファイル...] +``` + +Python 3.9以上で実行する。引数なしでリポジトリ直下の `*.md`、`journal/*.md`、`backlog/**/*.md` を全部見る。 +実行場所にかかわらずリポジトリを基準に分類し、明示したパスは実行ディレクトリから解決する。 +ERROR があれば終了コード 1。存在しない引数のファイルもERRORにする。 + +検出する項目: + +| 種別 | 内容 | +|---|---| +| ERROR | 本文の `[推測:P-n]` に対応する `## 未確認事項` の項目がない | +| ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない | +| ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない | +| ERROR | `## 未確認事項` のIDが重複している | +| ERROR | 仕様書の `## 保留課題` がリンク箇条書き以外を含む、空である、参照先ファイル・見出しがない | +| ERROR | 末尾の管理セクションが重複する、保留課題 → 検討済み・非採用 → 未確認事項の順でない | +| ERROR | backlogの項目IDが不正・検査対象内で重複する、必須5欄がない・空である | +| ERROR | backlogに推測タグがある、参照先ファイルが存在しない | +| ERROR | journal へのリンク / journal から他ファイルへのリンク | +| ERROR | `## 検討済み・非採用` の項目が journal ファイル名を参照していない | +| ERROR | journal のファイル名が `YYYY-MM-DD-.md` 形式でない | +| WARN | `## 検討済み・非採用` が5件を超えた | +| WARN | journal の冒頭20行に結論が見当たらない | +| WARN | メインドキュメントに比較表・優先度評価・却下案の記述らしきもの | + +**本文タグのない `## 未確認事項` の項目はエラーにしない。** 実装前に判断が必要な未解決の問いとして正当。 +設計判断は「完了条件」でよい。タグなしの項目が事実確認なのか設計判断なのかは、目視で確認する。 + +backlogでは `### PHY-P-14 題名` のように区分付きIDを使い、関連仕様・未決事項・保留できる理由・再開条件・完了条件を必須とする。 +仕様書側の書式は `- [PHYの保留課題](backlog/README.md#phy)`。 +backlogは短い候補・制約を保持できるため、メインドキュメント向けの散文ヒューリスティックは適用しない。 + +WARN の散文検出は当たりが粗い。取りこぼしも誤検出もあるので、出た/出ないを結論にしない。 + +## 2. スクリプトで見られない項目を確認する + +こちらが本体。ファイルを読んで判断する。 + +- **「なぜBではないのか」が理由として残っていないか。** + 却下理由ではなく、現在効いている制約として書かれているか。 + - ✗ `階層構造はKLEが非対応なので諦めた` + - ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)` +- **本文が前提にする未確認の記述にタグが付いているか。** 検査項目の中で最も重要で、 + 機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない + 記述を探し、`[推測:P-n]` が付いているか確認する。 +- **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。 +- **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。 +- **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。 +- **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。 +- **コミット規約**(必要なら `git log --oneline` を見る) + - 1コミット1決定になっているか + - journal を伴う決定のメッセージに `Journal:` 行があるか + +## 3. 報告する + +ERROR / WARN / 目視で見つけたもの を分けて出す。 +修正案は出してよいが、適用は確認を取ってから。事実関係の誤りを見つけた場合も、直さず指摘する。 + +## 注意 + +- 既存ドキュメントへのルール適用は段階的に進めている。 + WARN が大量に出るのは想定内で、再編(`/restructure-doc`)のタイミングで解消する。 + 再編前のファイルの WARN を個別に直そうとしない。 + +スクリプトを変更した場合の回帰検査: + +``` +python -B -m unittest discover -s .Codex/skills/doc-lint -p test_doc_lint.py +``` diff --git a/.agents/skills/doc-lint/doc-lint.py b/.agents/skills/doc-lint/doc-lint.py new file mode 100644 index 0000000..704e0d6 --- /dev/null +++ b/.agents/skills/doc-lint/doc-lint.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""CLAUDE.md の運用ルール1〜5のうち、機械的に判定できる項目を検査する。 + +使い方: + python .claude/skills/doc-lint/doc-lint.py [ファイル...] +引数なしならリポジトリ直下の *.md、journal/*.md、backlog/**/*.md が対象。 +明示したパスは実行ディレクトリから解決する。 +ERROR が1件でもあれば終了コード 1。 +""" +import re +import sys +from pathlib import Path +from urllib.parse import unquote, urlsplit + +TAG = re.compile(r"\[推測:(P-\d+)\]") +ENTRY = re.compile(r"^###\s+(P-\d+)\b") +H2 = re.compile(r"^##\s+(.*?)\s*$") +MDLINK = re.compile(r"\]\(([^)]+\.md[^)]*)\)") +JOURNALFN= re.compile(r"^\d{4}-\d{2}-\d{2}-.+\.md$") +PROMOTE = re.compile(r"^\s*[-*]\s*格上げ条件\s*[::]\s*\S") +COMPLETE = re.compile(r"^\s*[-*]\s*完了条件\s*[::]\s*\S") +BACKLOG_ENTRY = re.compile(r"^###\s+([A-Za-z][A-Za-z0-9_-]*-P-\d+)\s+\S") +BACKLOG_LINK = re.compile(r"^- \[[^\]]+の保留課題\]\(([^)]+)\)$") +BACKLOG_FIELDS = ("関連仕様", "未決事項", "保留できる理由", "再開条件", "完了条件") +MANAGEMENT_SECTIONS = ("保留課題", "検討済み・非採用", "未確認事項") +ROOT = Path(__file__).resolve().parents[3] + +# ルールブック自身は、アンチパターンを例として引用するため散文ヒューリスティックの対象外 +PROSE_EXEMPT = {"CLAUDE.md"} + +issues = [] +def add(sev, path, line, msg): + issues.append((sev, path, line, msg)) + +def load(path): + """コードフェンス内を空行に潰した (行番号, 本文) の列を返す。""" + raw = Path(path).read_text(encoding="utf-8").splitlines() + out, fence = [], False + for i, l in enumerate(raw, 1): + if l.lstrip().startswith("```"): + fence = not fence + out.append((i, "")) + continue + out.append((i, "" if fence else l)) + return out + +def section(lines, title): + """## 配下の (行番号, 本文) を返す。無ければ None。""" + start = None + for idx, (n, l) in enumerate(lines): + m = H2.match(l) + if m and m.group(1) == title: + start = idx + 1 + break + if start is None: + return None + body = [] + for n, l in lines[start:]: + if H2.match(l): + break + body.append((n, l)) + return body + +def local_target(path, target): + """相対リンクをリンク元基準で解決する。外部URLは対象外。""" + parsed = urlsplit(target) + if parsed.scheme or parsed.netloc: + return None, "" + linked = (path.parent / unquote(parsed.path)).resolve() if parsed.path else path + return linked, unquote(parsed.fragment) + + +def check_backlog(path, rel, lines, seen_ids): + blocks = [] + current = None + for n, line in lines: + if H2.match(line): + current = None + elif re.match(r"^###\s", line): + match = BACKLOG_ENTRY.match(line) + current = None + if not match: + add("ERROR", rel, n, "保留課題の見出しは ### <区分>-P-<番号> <題名> とする") + continue + pid = match.group(1) + if pid in seen_ids: + add("ERROR", rel, n, "保留課題のID %s が重複している(初出: %s)" % (pid, seen_ids[pid])) + seen_ids[pid] = "%s:%d" % (rel, n) + current = (pid, n, []) + blocks.append(current) + elif current is not None: + current[2].append(line) + + for pid, n, body in blocks: + for field in BACKLOG_FIELDS: + pattern = re.compile(r"^[-*]\s+" + re.escape(field) + r"\s*[::]\s*\S") + if not any(pattern.match(line) for line in body): + add("ERROR", rel, n, "%s に「- %s:」の内容がない" % (pid, field)) + + for n, line in lines: + if TAG.search(line): + add("ERROR", rel, n, "本文の前提となる推測タグはbacklogではなく仕様書に置く") + for target in MDLINK.findall(line): + linked, _ = local_target(path, target) + if linked is not None and not linked.is_file(): + add("ERROR", rel, n, "backlogの参照先ファイルがない: %s" % target) + + +def check_backlog_section(path, rel, lines): + positions = [] + for title in MANAGEMENT_SECTIONS: + headings = [n for n, line in lines if H2.match(line) and H2.match(line).group(1) == title] + if len(headings) > 1: + add("ERROR", rel, headings[1], "## %s が重複している" % title) + if headings: + positions.append(headings[0]) + if positions != sorted(positions): + add("ERROR", rel, min(positions), "末尾の管理セクションは保留課題 → 検討済み・非採用 → 未確認事項の順に置く") + + deferred = section(lines, "保留課題") + if deferred is None: + return + contents = [(n, line) for n, line in deferred if line.strip()] + if not contents: + add("ERROR", rel, 1, "空の ## 保留課題 はセクションごと削除する") + for n, line in contents: + match = BACKLOG_LINK.fullmatch(line) + if not match: + add("ERROR", rel, n, "保留課題には - [<区分>の保留課題](backlogのファイル#見出し) だけを書く") + continue + target = match.group(1) + linked, anchor = local_target(path, target) + if (linked is None or not linked.is_relative_to(ROOT / "backlog") + or linked.suffix != ".md" or not anchor): + add("ERROR", rel, n, "保留課題はbacklog内のMarkdownファイルの見出しへリンクする: %s" % target) + continue + if not linked.is_file(): + add("ERROR", rel, n, "保留課題の参照先ファイルがない: %s" % target) + continue + headings = set() + for _, linked_line in load(linked): + heading = re.match(r"^#{1,6}\s+(.+?)\s*#*\s*$", linked_line) + if heading: + slug = re.sub(r"[^\w\s-]", "", heading.group(1)).lower().replace(" ", "-") + headings.add(slug) + if anchor not in headings: + add("ERROR", rel, n, "保留課題の参照先見出しがない: %s" % target) + + +def check(path, seen_backlog_ids=None): + path = Path(path).resolve() + rel = path.relative_to(ROOT).as_posix() if path.is_relative_to(ROOT) else path.as_posix() + base = path.name + is_journal = path.is_relative_to(ROOT / "journal") + is_backlog = path.is_relative_to(ROOT / "backlog") + lines = load(path) + + if is_backlog: + check_backlog(path, rel, lines, seen_backlog_ids if seen_backlog_ids is not None else {}) + elif not is_journal: + check_backlog_section(path, rel, lines) + + unconf = section(lines, "未確認事項") + rejected = section(lines, "検討済み・非採用") + unconf_span = set(n for n, _ in unconf) if unconf else set() + tagged = {pid for n, line in lines if n not in unconf_span for pid in TAG.findall(line)} + + # --- ルール4: 推測タグと未確認事項の対応 --- + entries = set() + if unconf is not None: + blocks = [] # [(pid, 行番号, [本文行])] 重複IDも個別に検査する + for n, l in unconf: + m = ENTRY.match(l) + if m: + pid = m.group(1) + if pid in entries: + add("ERROR", rel, n, "未確認事項のID %s が重複している" % pid) + entries.add(pid) + blocks.append((pid, n, [])) + elif blocks: + blocks[-1][2].append(l) + for pid, ln, body in blocks: + has_promote = any(PROMOTE.match(x) for x in body) + has_complete = any(COMPLETE.match(x) for x in body) + if pid in tagged and not has_promote: + add("ERROR", rel, ln, + "%s に「- 格上げ条件:」の行がない(何を確認すれば事実に格上げできるか)" % pid) + elif not has_promote and not has_complete: + add("ERROR", rel, ln, + "%s に「- 格上げ条件:」または「- 完了条件:」の内容がない" % pid) + + for n, l in lines: + if n in unconf_span: + continue + for pid in TAG.findall(l): + if unconf is None or pid not in entries: + add("ERROR", rel, n, + "本文の [推測:%s] に対応する項目が ## 未確認事項 にない" % pid) + # 逆向き(本文タグのない未確認事項)は「未解決の問い」として正当なのでエラーにしない + + # --- ルール3: journal への/からのリンク禁止 --- + for n, l in lines: + for target in MDLINK.findall(l): + linked, _ = local_target(path, target) + if linked is not None and linked.is_relative_to(ROOT / "journal"): + add("ERROR", rel, n, "journal へのリンクが張られている: %s" % target) + elif is_journal and linked is not None: + add("ERROR", rel, n, "journal から他ファイルへのリンクが張られている: %s" % target) + + # --- ルール1: 検討済み・非採用 --- + if rejected is not None: + items = [(n, l) for n, l in rejected if l.strip().startswith(("-", "*"))] + for n, l in items: + if not re.search(r"\(journal/[^)]+\.md\)", l): + add("ERROR", rel, n, + "検討済み・非採用の項目が journal ファイル名を参照していない") + if len(items) > 5: + add("WARN", rel, items[5][0], + "検討済み・非採用が %d 件(目安5件)。古いものから落とす" % len(items)) + + # --- journal 固有 --- + if is_journal: + if not JOURNALFN.match(base): + add("ERROR", rel, 1, "journal のファイル名が YYYY-MM-DD-<topic>.md 形式でない") + head = " ".join(l for n, l in lines[:20]) + if "結論" not in head: + add("WARN", rel, 1, "冒頭20行に結論が見当たらない(結論を先頭に置く)") + + # --- ルール1: 調査・比較の混入(要目視確認) --- + if not is_journal and not is_backlog and base not in PROSE_EXEMPT: + # 検討済み・非採用 は却下案を列挙する場所なので対象外 + skip = set(n for n, _ in rejected) if rejected else set() + for n, l in lines: + if n in skip: + continue + if "メリット" in l and "デメリット" in l: + add("WARN", rel, n, "比較表らしき記述(メリット/デメリット)。journal 行きでは?") + elif re.search(r"却下|不採用|見送っ|も検討したが|案[A-Z]|[A-Z]案", l): + add("WARN", rel, n, "却下案の検討過程らしき記述。制約文に書き換えるか journal へ") + elif re.search(r"優先度", l) and l.lstrip().startswith("#"): + add("WARN", rel, n, "優先度評価は journal 行きでは?") + +def main(argv): + issues.clear() + targets = [Path(arg).resolve() for arg in argv[1:]] + if not targets: + targets = (sorted(ROOT.glob("*.md")) + sorted((ROOT / "journal").glob("*.md")) + + sorted((ROOT / "backlog").rglob("*.md"))) + targets = list(dict.fromkeys(targets)) + if not targets: + print("対象ファイルなし") + return 0 + seen_backlog_ids = {} + for t in targets: + if t.is_file(): + check(t, seen_backlog_ids) + else: + add("ERROR", t.as_posix(), 1, "対象ファイルが見つからない") + + errs = [i for i in issues if i[0] == "ERROR"] + warns = [i for i in issues if i[0] == "WARN"] + for sev, path, line, msg in errs + warns: + print("%-5s %s:%d %s" % (sev, path, line, msg)) + print("\n%d files / ERROR %d / WARN %d" % (len(targets), len(errs), len(warns))) + return 1 if errs else 0 + +if __name__ == "__main__": + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + sys.exit(main(sys.argv)) diff --git a/.agents/skills/doc-lint/test_doc_lint.py b/.agents/skills/doc-lint/test_doc_lint.py new file mode 100644 index 0000000..dedb275 --- /dev/null +++ b/.agents/skills/doc-lint/test_doc_lint.py @@ -0,0 +1,150 @@ +"""文書の分類・参照・完了条件の回帰検査。標準ライブラリだけで実行する。""" +import contextlib +import importlib.util +import io +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +spec = importlib.util.spec_from_file_location("doc_lint", Path(__file__).with_name("doc-lint.py")) +lint = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lint) + + +def backlog_item(pid="PHY-P-14"): + return ( + f"### {pid} 原点の吸着\n\n" + "- 関連仕様: [PHY](../PHY.md)\n" + "- 未決事項: 吸着が必要か\n" + "- 保留できる理由: 数値入力で編集できる\n" + "- 再開条件: 原点移動を試作するとき\n" + "- 完了条件: 吸着の要否を決める\n" + ) + + +class DocLintTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + root_patch = patch.object(lint, "ROOT", self.root) + root_patch.start() + self.addCleanup(root_patch.stop) + self.write("PHY.md", "# PHY\n") + + def write(self, relative, content): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + def run_lint(self, *paths): + output = io.StringIO() + with contextlib.redirect_stdout(output): + status = lint.main(["doc-lint.py", *(str(path) for path in paths)]) + return status, output.getvalue() + + def test_default_scan_includes_backlog_and_accepts_fixed_links(self): + self.write("PHY.md", "# PHY\n\n## 保留課題\n\n- [PHYの保留課題](backlog/README.md#phy)\n") + self.write("backlog/README.md", "# 保留課題\n\n## PHY\n\n" + backlog_item()) + self.write("journal/2026-09-22-decision.md", "# 決定\n\n## 結論\n\n採用した。\n") + status, output = self.run_lint() + self.assertEqual(status, 0, output) + self.assertIn("3 files / ERROR 0", output) + + def test_backlog_fields_must_have_values(self): + for field in lint.BACKLOG_FIELDS: + with self.subTest(field=field): + text = backlog_item() + text = "\n".join(f"- {field}: " if line.startswith(f"- {field}:") else line + for line in text.splitlines()) + path = self.write("backlog/README.md", "## PHY\n" + text) + status, output = self.run_lint(path) + self.assertEqual(status, 1) + self.assertIn(f"- {field}:", output) + + def test_duplicate_backlog_ids_across_nested_files(self): + self.write("backlog/one.md", "## PHY\n" + backlog_item()) + self.write("backlog/sub/two.md", "## PHY\n" + backlog_item().replace("../PHY.md", "../../PHY.md")) + status, output = self.run_lint() + self.assertEqual(status, 1) + self.assertIn("保留課題のID PHY-P-14 が重複", output) + + def test_incomplete_last_item_cannot_borrow_fields_from_next_section(self): + self.write("backlog/README.md", "## PHY\n### PHY-P-14 吸着\n\n## ELC\n" + backlog_item("ELC-P-1")) + status, output = self.run_lint() + self.assertEqual(status, 1) + self.assertIn("PHY-P-14 に「- 完了条件:」", output) + + def test_invalid_backlog_heading_and_missing_reference(self): + path = self.write("backlog/README.md", "## PHY\n### P-14 吸着\n" + backlog_item().replace("../PHY.md", "../missing.md")) + status, output = self.run_lint(path) + self.assertEqual(status, 1) + self.assertIn("保留課題の見出し", output) + self.assertIn("参照先ファイルがない", output) + + def test_spec_backlog_section_rejects_prose_empty_and_broken_targets(self): + self.write("backlog/README.md", "## PHY\n" + backlog_item()) + cases = { + "内容をここにも要約する。": "だけを書く", + "": "空の ## 保留課題", + "- [PHYの保留課題](backlog/missing.md#phy)": "参照先ファイルがない", + "- [PHYの保留課題](backlog/README.md#missing)": "参照先見出しがない", + "- [PHYの保留課題](backlog/README.md)": "見出しへリンク", + "- [PHYの保留課題](PHY.md#phy)": "backlog内", + } + for content, expected in cases.items(): + with self.subTest(content=content): + path = self.write("PHY.md", "# PHY\n\n## 保留課題\n\n" + content + "\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1) + self.assertIn(expected, output) + + def test_management_sections_have_one_order(self): + path = self.write("PHY.md", "## 未確認事項\n\n(なし)\n\n## 検討済み・非採用\n\n## 未確認事項\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1) + self.assertIn("順に置く", output) + self.assertIn("## 未確認事項 が重複", output) + + def test_decisions_accept_completion_but_tagged_assumptions_require_evidence(self): + text = "## 未確認事項\n\n### P-1 操作方式\n- 完了条件: 操作方式を決める\n" + path = self.write("PHY.md", text) + self.assertEqual(self.run_lint(path)[0], 0) + path.write_text("本文 [推測:P-1]\n\n" + text, encoding="utf-8") + status, output = self.run_lint(path) + self.assertEqual(status, 1) + self.assertIn("格上げ条件", output) + path.write_text("本文 [推測:P-1]\n\n" + text.replace("完了条件", "格上げ条件"), encoding="utf-8") + self.assertEqual(self.run_lint(path)[0], 0) + + def test_absolute_journal_paths_keep_journal_checks(self): + path = self.write("journal/bad.md", "# 調査\n\n[仕様](../PHY.md)\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1) + self.assertIn("journal のファイル名", output) + self.assertIn("journal から他ファイルへのリンク", output) + + def test_backlog_is_not_prose_linted_but_cannot_link_to_journal(self): + self.write("journal/2026-09-22-decision.md", "## 結論\n") + path = self.write("backlog/README.md", "## PHY\n" + backlog_item() + "\n候補はA案。\n[履歴](../journal/2026-09-22-decision.md)\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1) + self.assertIn("journal へのリンク", output) + self.assertIn("WARN 0", output) + + def test_code_examples_do_not_create_real_sections_or_tags(self): + path = self.write("PHY.md", "```markdown\n## 保留課題\n[推測:P-9]\n```\n") + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + + def test_missing_explicit_file_is_an_error(self): + status, output = self.run_lint(self.root / "missing.md") + self.assertEqual(status, 1) + self.assertIn("対象ファイルが見つからない", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/restructure-doc/SKILL.md b/.agents/skills/restructure-doc/SKILL.md new file mode 100644 index 0000000..654a5ae --- /dev/null +++ b/.agents/skills/restructure-doc/SKILL.md @@ -0,0 +1,96 @@ +--- +name: restructure-doc +description: 既存ドキュメント1ファイルをAGENTS.mdのルール1〜4に沿って仕分ける。事実・推測・採用案を残し、調査過程をjournalへ移す。 +disable-model-invocation: true +argument-hint: <ファイル名> +--- + +# ドキュメントを再編する + +運用ルール策定前に書かれたドキュメントを、AGENTS.md のルール1〜4に沿った形に作り直す。 +**1回の実行で1ファイルだけ**扱う。 + +## 最重要の制約 + +**情報を失わせない。** この作業は「移動と再配置」であって、要約でも削除でもない。 + +- 内容を短くしない。判断に迷った記述は、消さずに残してユーザーに聞く。 +- メインドキュメントから外す記述は、必ず journal に移す。捨てない。 +- 元の主張を勝手に言い換えない。特に断定度を上げ下げしない。 + +## 手順 + +### 1. 全体を読む + +対象ファイルを最初から最後まで読む。部分的に読んで判断しない。 + +### 2. 4分類する + +各ブロック(見出し単位、または段落単位)を次のどれかに分ける。 + +| 分類 | 行き先 | +|---|---| +| a. 採用した案 / 現在の設定 / 設計に影響する制約 | メインドキュメントに残す | +| b. 裏取りの済んだ事実 | メインドキュメントに残す(文中のどこでもよい) | +| c. 未確認の推測・未解決の問い | メインドキュメント末尾の `## 未確認事項` | +| d. 調査過程 / 案の比較 / 却下案 / 優先度評価 | `journal/` へ移す | + +判定のヒント: + +- メリット/デメリット表、優先度リスト、「〜も検討したが」 → d +- 「〜できるはず」「〜と考えられる」「実現可能」で、実際に試していない → c +- 外部仕様の記述(KLEのプロパティ定義など)は、出典を確認できていれば b、していなければ c + +### 3. 分類結果を提示して承認を得る + +**ここで必ず止まる。** ファイルを書き換える前に、分類の一覧をユーザーに見せて確認する。 +特に b と c の境目(裏取りできているか)は自分で判断せず聞く。 + +### 4. メインドキュメントを書き直す + +- a を中心に再構成する。b は文中に自然に置く。 +- 「なぜBではないのか」の記述が残っていたら、**制約文**に書き換える。 + - ✗ `階層構造はKLEが非対応なので諦めた` + - ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)` +- 落とした案があれば末尾 `## 検討済み・非採用` に1案1行(理由なし、journalファイル名を参照)。 + +### 5. 未確認事項を立てる + +c に分類したものを末尾の `## 未確認事項` にまとめる。 + +- 本文が前提にしている推測 → 本文に `[推測:P-n]` を付け、対応する項目を置く +- まだ本文に書けない未解決の問い → 本文タグなしで項目だけ置く +- 各項目に**格上げ条件**を必ず書く。思いつかないものはユーザーに聞く(空欄で残さない) + +```markdown +### P-1 KLEはlabels[9]の内容を保存時に保持する +- 位置づけ: 本文「クラスター管理」が前提にしている +- 格上げ条件: KLE本家でインポート→エクスポートし、labels[9]が残ることを確認する +``` + +### 6. journal へ移す + +d を `journal/YYYY-MM-DD-<元ファイル名>.md` に移す。 + +- 冒頭に**結論**(このファイルの再編で何が確定し、何を落としたか) +- 次に案の比較(元ファイルの表をそのまま持ってきてよい) +- 以下に元の調査記述 +- 整形は最低限でよい + +### 7. コミットする + +1ファイル1コミット。メインドキュメントと journal を同じコミットに入れる。 + +``` +<ファイル名> を運用ルールに沿って再編 + +<何を残し、何をjournalへ移したか 数行> + +Journal: journal/2026-09-16-<元ファイル名>.md +``` + +## 注意 + +- 複数ファイルを一度に触らない。ファイル間で内容が重複していると気づいた場合は、 + 勝手に統合せず、指摘だけしてそのファイルの再編を終える。 +- 再編の途中で事実関係の誤りを見つけても、勝手に直さない。指摘する。