diff --git a/.agents/skills/decide/SKILL.md b/.agents/skills/decide/SKILL.md index e36052d..2517838 100644 --- a/.agents/skills/decide/SKILL.md +++ b/.agents/skills/decide/SKILL.md @@ -7,8 +7,9 @@ argument-hint: [決定の要旨] # 決定を記録する -AGENTS.md のルール1〜4に従い、決定を4箇所に分散して記録する。 -どれか1つでも欠けると後から追えなくなるので、全部やる。 +共通の運用ルールは、AGENTS.mdの参照先であるCLAUDE.mdを正とする。 +現在の仕様はメインドキュメント、判断の経緯はjournal、変更の要旨はコミットに記録する。 +「検討済み・非採用」には、将来の再提案を防ぐために必要な案を選んで残す。 ## 手順 @@ -30,17 +31,22 @@ AGENTS.md のルール1〜4に従い、決定を4箇所に分散して記録す - ✗ `B案はKLEパーサーが壊れるので却下した` - ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)` - 比較表・検討過程・ベンチマーク結果をここに書かない。 +- 他の担当の詳細を外へ移したあとも、この文書だけで担当範囲が分かるか確認する。 + 境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。 -### 2. 落とした案を1行残す +### 2. 非採用一覧に残す案を選ぶ -メインドキュメント末尾の `## 検討済み・非採用` に追記する。 +各案について、この記録が将来のどんな再提案を防ぐかを判断する。 +本文の制約だけで十分なら一覧へ重複記載しない。実際に検討した別方針で、 +再提案されうるものは、経緯への入口として末尾の `## 検討済み・非採用` に1案1行で残す。 ``` - labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md) ``` - 理由は書かない。journalファイル名だけ。 -- セクションが5行を超えたら、古いものから落とす。長くなるのは書きすぎのサイン。 +- 目安は5行まで。件数や古さだけで機械的に削除せず、再提案を防ぐ必要性で掲載を見直す。 +- 掲載する案がなければセクションを設けない。一覧から外しても経緯はjournalに残す。 ### 3. journal を書く @@ -55,7 +61,7 @@ AGENTS.md のルール1〜4に従い、決定を4箇所に分散して記録す 冒頭20行で結論と選択肢が分かること。整形は最低限でよい。 他ファイルへのリンクも、他ファイルからのリンクも張らない。 -### 4. 推測を扱う +### 4. 推測と未決の問いを扱う この決定が未確認の推測に依存する場合: @@ -65,6 +71,11 @@ AGENTS.md のルール1〜4に従い、決定を4箇所に分散して記録す 逆に、この決定で検証が済んだ推測があれば、タグを外して `## 未確認事項` から削除する。 本文タグと末尾項目は必ず対で操作する(片方だけ残さない)。 +本文の未決記述は、読者が現在の対応範囲を理解するために必要かを判断する。 +対応する問いがあればCLAUDE.mdのルール4に従ってリンクし、問いの詳細は複製しない。 +必要な問いが未登録なら未確認事項を追加し、実装にも現在の説明にも不要な言及なら本文から外す。 +今は答えに依存せず進められる課題はルール5に従う。「未決」という単語だけで分類しない。 + ### 5. コミットする メインドキュメントと journal を**同じコミット**に入れる。1コミット1決定。 diff --git a/.agents/skills/doc-lint/SKILL.md b/.agents/skills/doc-lint/SKILL.md index 543920f..c58a5ec 100644 --- a/.agents/skills/doc-lint/SKILL.md +++ b/.agents/skills/doc-lint/SKILL.md @@ -7,12 +7,13 @@ argument-hint: [ファイル...] # ドキュメントのルール違反を検査する 機械的に判定できる項目はスクリプトで検出し、判断が要る項目だけ目視で見る。 +共通の運用ルールは、AGENTS.mdの参照先であるCLAUDE.mdを正とする。 **検出して報告するだけで、勝手に直さない。** 修正は内容を確認してもらってから。 ## 1. スクリプトを走らせる ``` -python .Codex/skills/doc-lint/doc-lint.py [ファイル...] +python .agents/skills/doc-lint/doc-lint.py [ファイル...] ``` Python 3.9以上で実行する。引数なしでリポジトリ直下の `*.md`、`journal/*.md`、`backlog/**/*.md` を全部見る。 @@ -27,6 +28,8 @@ ERROR があれば終了コード 1。存在しない引数のファイルもERR | ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない | | ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない | | ERROR | `## 未確認事項` のIDが重複している | +| ERROR | 未確認事項へのローカルリンクの参照先ファイル・見出しがない、または個別項目へのリンクが未確認事項の項目を指していない | +| ERROR | リンクラベルの課題番号と参照先の課題番号が一致しない | | ERROR | 仕様書の `## 保留課題` がリンク箇条書き以外を含む、空である、参照先ファイル・見出しがない | | ERROR | 末尾の管理セクションが重複する、保留課題 → 検討済み・非採用 → 未確認事項の順でない | | ERROR | backlogの項目IDが不正・検査対象内で重複する、必須5欄がない・空である | @@ -35,6 +38,7 @@ ERROR があれば終了コード 1。存在しない引数のファイルもERR | ERROR | `## 検討済み・非採用` の項目が journal ファイル名を参照していない | | ERROR | journal のファイル名が `YYYY-MM-DD-.md` 形式でない | | WARN | `## 検討済み・非採用` が5件を超えた | +| WARN | 本文の段落・箇条書き項目に、リンクのない課題番号や、課題への参照を伴わない「未決」「未定」がある | | WARN | journal の冒頭20行に結論が見当たらない | | WARN | メインドキュメントに比較表・優先度評価・却下案の記述らしきもの | @@ -46,6 +50,11 @@ backlogでは `### PHY-P-14 題名` のように区分付きIDを使い、関連 backlogは短い候補・制約を保持できるため、メインドキュメント向けの散文ヒューリスティックは適用しない。 WARN の散文検出は当たりが粗い。取りこぼしも誤検出もあるので、出た/出ないを結論にしない。 +未確認事項リンクの検査対象は `[P-3](#p-3-...)`・`[MAPのP-3](MAPレイヤー.md#p-3-...)` 等の +インラインリンク。Markdown全般のリンク検査ではない。 +「未決」「未定」のWARNは、次行の参照も同じ段落として扱う。コード例、末尾の管理セクション、 +journal、backlog、ルールブック自体はこのWARNの対象外とする。 +WARNから課題の追加や本文の削除を自動決定しない。 ## 2. スクリプトで見られない項目を確認する @@ -59,6 +68,15 @@ WARN の散文検出は当たりが粗い。取りこぼしも誤検出もある 機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない 記述を探し、`[推測:P-n]` が付いているか確認する。 - **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。 +- **他の担当の説明を移した結果、責務の境界まで消えていないか。** + この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。 +- **非採用一覧の各項目は、将来のどんな再提案を防ぐか。** + 本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。 + 件数・古さ・重複だけで一律に追加・削除しない。 +- **本文の未決記述は、読者に何を伝えるためにあるか。** + 現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。 + 対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。 + リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。 - **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。 - **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。 - **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。 @@ -68,7 +86,9 @@ WARN の散文検出は当たりが粗い。取りこぼしも誤検出もある ## 3. 報告する -ERROR / WARN / 目視で見つけたもの を分けて出す。 +機械検査のERROR / WARNと、目視で確認した内容・未確認の範囲を分けて報告する。 +機械検査が0件でも、内容の分類・責務の境界・記録の必要性が適切だった証明にはしない。 +目視確認を省いた場合はその旨を明記する。 修正案は出してよいが、適用は確認を取ってから。事実関係の誤りを見つけた場合も、直さず指摘する。 ## 注意 @@ -80,5 +100,5 @@ ERROR / WARN / 目視で見つけたもの を分けて出す。 スクリプトを変更した場合の回帰検査: ``` -python -B -m unittest discover -s .Codex/skills/doc-lint -p test_doc_lint.py +python -B -m unittest discover -s .agents/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 index 704e0d6..faf5f1a 100644 --- a/.agents/skills/doc-lint/doc-lint.py +++ b/.agents/skills/doc-lint/doc-lint.py @@ -3,7 +3,7 @@ """CLAUDE.md の運用ルール1〜5のうち、機械的に判定できる項目を検査する。 使い方: - python .claude/skills/doc-lint/doc-lint.py [ファイル...] + python .agents/skills/doc-lint/doc-lint.py [ファイル...] 引数なしならリポジトリ直下の *.md、journal/*.md、backlog/**/*.md が対象。 明示したパスは実行ディレクトリから解決する。 ERROR が1件でもあれば終了コード 1。 @@ -17,6 +17,11 @@ 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[^)]*)\)") +INLINE_LINK = re.compile(r"(?\n]+>|[^\s)]+)\s*\)") +INLINE_CODE = re.compile(r"(\x60+).*?\1", re.S) +ISSUE_ID = re.compile(r"(?= fence[1] and not marker.group(2).strip()): + fence = None + continue + if marker: + fence = (marker.group(1)[0], len(marker.group(1))) out.append((i, "")) continue - out.append((i, "" if fence else l)) + out.append((i, l)) return out def section(lines, title): @@ -71,6 +83,108 @@ def local_target(path, target): return linked, unquote(parsed.fragment) +def heading_index(lines): + """ATX見出しのアンカーを作る。同じ見出しの連番も扱う。""" + headings = {} + for n, line in lines: + match = HEADING.match(line) + if not match: + continue + title = match.group(1) + slug = re.sub(r"[^\w\s-]", "", title).lower().replace(" ", "-") + anchor, suffix = slug, 0 + while anchor in headings: + suffix += 1 + anchor = f"{slug}-{suffix}" + headings[anchor] = (n, title) + return headings + + +def body_paragraphs(lines): + """改行をまたぐ本文段落を返す。見出し・空行・次の箇条書きで区切る。""" + paragraph = [] + in_management = False + for n, line in lines: + if HEADING.match(line): + if paragraph: + yield paragraph + paragraph = [] + h2 = H2.match(line) + if h2: + in_management = h2.group(1) in MANAGEMENT_SECTIONS + elif line.startswith("# "): + in_management = False + continue + if in_management: + continue + if not line.strip() or re.match(r"^\s*(?:[-*+]|\d+[.)])\s+", line): + if paragraph: + yield paragraph + paragraph = [] + if line.strip(): + paragraph.append((n, line)) + if paragraph: + yield paragraph + + +def check_issue_references(path, rel, lines): + """課題リンクの構造はERROR、本文の参照漏れの候補はWARNにする。""" + targets = {} + valid_link_lines = set() + # インラインコードも位置を変えずにマスクする。 + text = "\n".join(line for _, line in lines) + masked = INLINE_CODE.sub(lambda m: re.sub(r"[^\n]", " ", m.group()), text).split("\n") + prose_lines = [(n, line) for (n, _), line in zip(lines, masked)] + for n, line in prose_lines: + for match in INLINE_LINK.finditer(line): + label, target = match.groups() + target = target.strip("<>") + linked, anchor = local_target(path, target) + if linked is None: + continue + label_ids = ISSUE_ID.findall(label) + if not (label_ids or ISSUE_ANCHOR.match(anchor) or anchor == "未確認事項"): + continue + if not linked.is_file(): + add("ERROR", rel, n, "未確認事項リンクの参照先ファイルがない: %s" % target) + continue + if linked not in targets: + target_lines = load(linked) + entries = { + ln: ENTRY.match(value).group(1) + for ln, value in (section(target_lines, "未確認事項") or []) + if ENTRY.match(value) + } + targets[linked] = (heading_index(target_lines), entries) + headings, entries = targets[linked] + if not anchor or anchor not in headings: + add("ERROR", rel, n, "未確認事項リンクの参照先見出しがない: %s" % target) + continue + target_line, _ = headings[anchor] + if anchor == "未確認事項" and not label_ids: + continue + target_id = entries.get(target_line) + if target_id is None: + add("ERROR", rel, n, "課題リンクが未確認事項の個別項目を指していない: %s" % target) + elif any(pid != target_id for pid in label_ids): + add("ERROR", rel, n, "リンクラベルの課題番号と参照先が一致しない: %s → %s" % (label, target_id)) + else: + valid_link_lines.add(n) + + for paragraph in body_paragraphs(prose_lines): + n = paragraph[0][0] + text = "\n".join(line for _, line in paragraph) + # リンクと推測タグには別の構造検査がある。裸の課題番号だけを探す。 + plain = TAG.sub("", INLINE_LINK.sub("", text)) + bare_ids = sorted(set(ISSUE_ID.findall(plain))) + if bare_ids: + add("WARN", rel, n, "本文にリンクのない課題番号がある(要確認): %s" % ", ".join(bare_ids)) + if (re.search(r"未決|未定", plain) + and not any(ln in valid_link_lines for ln, _ in paragraph) + and not TAG.search(text)): + add("WARN", rel, n, "本文の未決・未定に課題への参照がない(要確認)。リンク・課題追加・言及削除を判断する") + + def check_backlog(path, rel, lines, seen_ids): blocks = [] current = None @@ -138,12 +252,7 @@ def check_backlog_section(path, rel, lines): 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) + headings = heading_index(load(linked)) if anchor not in headings: add("ERROR", rel, n, "保留課題の参照先見出しがない: %s" % target) @@ -160,6 +269,8 @@ def check(path, seen_backlog_ids=None): 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) + if base not in PROSE_EXEMPT: + check_issue_references(path, rel, lines) unconf = section(lines, "未確認事項") rejected = section(lines, "検討済み・非採用") @@ -217,7 +328,7 @@ def check(path, seen_backlog_ids=None): "検討済み・非採用の項目が journal ファイル名を参照していない") if len(items) > 5: add("WARN", rel, items[5][0], - "検討済み・非採用が %d 件(目安5件)。古いものから落とす" % len(items)) + "検討済み・非採用が %d 件(目安5件)。再提案を防ぐ必要性で掲載を見直す" % len(items)) # --- journal 固有 --- if is_journal: diff --git a/.agents/skills/doc-lint/test_doc_lint.py b/.agents/skills/doc-lint/test_doc_lint.py index dedb275..e34ee3e 100644 --- a/.agents/skills/doc-lint/test_doc_lint.py +++ b/.agents/skills/doc-lint/test_doc_lint.py @@ -5,6 +5,7 @@ import io import tempfile import unittest from pathlib import Path +from urllib.parse import quote from unittest.mock import patch @@ -24,6 +25,14 @@ def backlog_item(pid="PHY-P-14"): ) +def issue_section(): + return ( + "## 未確認事項\n\n" + "### P-1 動作の範囲\n\n- 完了条件: 範囲を決める\n\n" + "### P-3 対応・状態管理\n\n- 完了条件: 管理方法を決める\n" + ) + + class DocLintTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() @@ -145,6 +154,147 @@ class DocLintTests(unittest.TestCase): self.assertEqual(status, 1) self.assertIn("対象ファイルが見つからない", output) + def test_issue_links_accept_same_file_cross_file_and_encoded_anchors(self): + self.write("MAPレイヤー.md", "# MAP\n\n" + issue_section()) + encoded = quote("MAPレイヤー.md") + "#" + quote("p-3-対応状態管理") + path = self.write( + "PHY.md", + "# PHY\n\n範囲は未決。\n[P-1](#p-1-動作の範囲)で扱う。\n\n" + "[MAPのP-3](MAPレイヤー.md#p-3-対応状態管理)へ。\n\n" + f"[MAPのP-3]({encoded})へ。\n\n" + issue_section(), + ) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("ERROR 0 / WARN 0", output) + + def test_issue_links_reject_missing_file_heading_and_fragment(self): + self.write("MAP.md", "# MAP\n\n" + issue_section()) + for target, expected in ( + ("missing.md#p-3-対応状態管理", "参照先ファイルがない"), + ("MAP.md#p-3-古い題名", "参照先見出しがない"), + ("MAP.md", "参照先見出しがない"), + ): + with self.subTest(target=target): + path = self.write("PHY.md", f"[P-3]({target})\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn(expected, output) + + def test_issue_label_must_match_real_entry_in_unconfirmed_section(self): + self.write("MAP.md", "# MAP\n\n### P-2 本文の見出し\n\n" + issue_section()) + for link, expected in ( + ("[P-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"), + ("[MAPのP-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"), + ("[P-2](MAP.md#p-2-本文の見出し)", "個別項目を指していない"), + ("[P-3](MAP.md#未確認事項)", "個別項目を指していない"), + ): + with self.subTest(link=link): + path = self.write("PHY.md", link + "\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn(expected, output) + + def test_duplicate_heading_slug_resolves_actual_target(self): + self.write("MAP.md", "# MAP\n\n### P-1 動作の範囲\n\n" + issue_section()) + path = self.write("PHY.md", "[P-1](MAP.md#p-1-動作の範囲-1)\n") + self.assertEqual(self.run_lint(path)[0], 0) + path.write_text("[P-1](MAP.md#p-1-動作の範囲)\n", encoding="utf-8") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn("個別項目を指していない", output) + + def test_unresolved_prose_warns_without_failing_or_modifying_documents(self): + text = "# PHY\n\n方式は未定。P-3で検討する。\n\n" + issue_section() + path = self.write("PHY.md", text) + before = path.read_bytes() + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("リンクのない課題番号", output) + self.assertIn("未決・未定に課題への参照がない", output) + self.assertEqual(path.read_bytes(), before) + + def test_another_issue_link_does_not_hide_bare_id(self): + path = self.write( + "PHY.md", "P-3と[P-1](#p-1-動作の範囲)を参照。\n\n" + issue_section() + ) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("リンクのない課題番号がある(要確認): P-3", output) + + def test_unrelated_links_and_section_links_do_not_hide_unresolved_prose(self): + for link in ("[仕様](PHY.md)", "[一覧](#未確認事項)", "[外部](https://example.com/)"): + with self.subTest(link=link): + path = self.write("PHY.md", f"範囲は未決。{link}\n\n" + issue_section()) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("未決・未定に課題への参照がない", output) + + def test_broken_link_does_not_count_as_resolved_reference(self): + path = self.write("PHY.md", "範囲は未決。\n[P-1](#p-1-missing)\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn("参照先見出しがない", output) + self.assertIn("未決・未定に課題への参照がない", output) + + def test_paragraph_and_list_boundaries_do_not_borrow_links(self): + for body in ( + "範囲は未決。\n\n[P-1](#p-1-動作の範囲)\n", + "- 範囲は未決。\n- [P-1](#p-1-動作の範囲)\n", + "1. 範囲は未決。\n2. [P-1](#p-1-動作の範囲)\n", + "範囲は未決。\n## 次の話\n[P-1](#p-1-動作の範囲)\n", + ): + with self.subTest(body=body): + path = self.write("PHY.md", body + "\n" + issue_section()) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("未決・未定に課題への参照がない", output) + path = self.write( + "PHY.md", "- 範囲は未決。\n [P-1](#p-1-動作の範囲)\n\n" + issue_section() + ) + self.assertIn("ERROR 0 / WARN 0", self.run_lint(path)[1]) + + def test_code_examples_external_links_and_assumptions_are_not_prose_warnings(self): + path = self.write( + "PHY.md", + "\x60未決 P-8 [P-8](#missing)\x60\n\n" + "\x60\x60\x60markdown\n未定 P-8 [P-8](#missing)\n\x60\x60\x60\n\n" + "~~~markdown\n未定 P-8 [P-8](#missing)\n~~~\n\n" + "[P-99](https://example.com/#p-99)\n\n" + "範囲は未定 [推測:P-1]。\n\n" + "## 未確認事項\n\n### P-1 調査\n\n- 格上げ条件: 検証する\n", + ) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("ERROR 0 / WARN 0", output) + + def test_management_sections_and_non_spec_files_are_exempt_from_prose_warnings(self): + self.write("CLAUDE.md", "未決 P-8 [P-8](#missing)\n") + self.write("journal/2026-09-22-decision.md", "## 結論\n\n未定 P-8\n") + self.write("backlog/README.md", "## PHY\n\n" + backlog_item() + "\n未定 P-8\n") + self.write("PHY.md", "# PHY\n\n" + issue_section() + "\n未定 P-8\n") + status, output = self.run_lint() + self.assertEqual(status, 0, output) + self.assertIn("WARN 0", output) + + def test_links_inside_unconfirmed_items_are_still_checked(self): + path = self.write("PHY.md", issue_section() + "\n[P-8](#missing)\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn("参照先見出しがない", output) + + def test_rejected_count_warning_does_not_select_entries_for_deletion(self): + path = self.write( + "PHY.md", "## 検討済み・非採用\n\n" + + "".join(f"- 案{i} (journal/2026-09-22-decision.md)\n" for i in range(6)) + ) + before = path.read_bytes() + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("6 件(目安5件)", output) + self.assertIn("再提案を防ぐ必要性", output) + self.assertNotIn("古いものから落とす", output) + self.assertEqual(path.read_bytes(), before) + if __name__ == "__main__": unittest.main() diff --git a/.claude/skills/decide/SKILL.md b/.claude/skills/decide/SKILL.md index 5c8100d..eb92b52 100644 --- a/.claude/skills/decide/SKILL.md +++ b/.claude/skills/decide/SKILL.md @@ -7,8 +7,9 @@ argument-hint: [決定の要旨] # 決定を記録する -CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録する。 -どれか1つでも欠けると後から追えなくなるので、全部やる。 +共通の運用ルールは CLAUDE.md を正とする。 +現在の仕様はメインドキュメント、判断の経緯はjournal、変更の要旨はコミットに記録する。 +「検討済み・非採用」には、将来の再提案を防ぐために必要な案を選んで残す。 ## 手順 @@ -30,17 +31,22 @@ CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録す - ✗ `B案はKLEパーサーが壊れるので却下した` - ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)` - 比較表・検討過程・ベンチマーク結果をここに書かない。 +- 他の担当の詳細を外へ移したあとも、この文書だけで担当範囲が分かるか確認する。 + 境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。 -### 2. 落とした案を1行残す +### 2. 非採用一覧に残す案を選ぶ -メインドキュメント末尾の `## 検討済み・非採用` に追記する。 +各案について、この記録が将来のどんな再提案を防ぐかを判断する。 +本文の制約だけで十分なら一覧へ重複記載しない。実際に検討した別方針で、 +再提案されうるものは、経緯への入口として末尾の `## 検討済み・非採用` に1案1行で残す。 ``` - labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md) ``` - 理由は書かない。journalファイル名だけ。 -- セクションが5行を超えたら、古いものから落とす。長くなるのは書きすぎのサイン。 +- 目安は5行まで。件数や古さだけで機械的に削除せず、再提案を防ぐ必要性で掲載を見直す。 +- 掲載する案がなければセクションを設けない。一覧から外しても経緯はjournalに残す。 ### 3. journal を書く @@ -55,7 +61,7 @@ CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録す 冒頭20行で結論と選択肢が分かること。整形は最低限でよい。 他ファイルへのリンクも、他ファイルからのリンクも張らない。 -### 4. 推測を扱う +### 4. 推測と未決の問いを扱う この決定が未確認の推測に依存する場合: @@ -65,6 +71,11 @@ CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録す 逆に、この決定で検証が済んだ推測があれば、タグを外して `## 未確認事項` から削除する。 本文タグと末尾項目は必ず対で操作する(片方だけ残さない)。 +本文の未決記述は、読者が現在の対応範囲を理解するために必要かを判断する。 +対応する問いがあればCLAUDE.mdのルール4に従ってリンクし、問いの詳細は複製しない。 +必要な問いが未登録なら未確認事項を追加し、実装にも現在の説明にも不要な言及なら本文から外す。 +今は答えに依存せず進められる課題はルール5に従う。「未決」という単語だけで分類しない。 + ### 5. コミットする メインドキュメントと journal を**同じコミット**に入れる。1コミット1決定。 diff --git a/.claude/skills/doc-lint/SKILL.md b/.claude/skills/doc-lint/SKILL.md index 13f1531..9474225 100644 --- a/.claude/skills/doc-lint/SKILL.md +++ b/.claude/skills/doc-lint/SKILL.md @@ -7,6 +7,7 @@ argument-hint: [ファイル...] # ドキュメントのルール違反を検査する 機械的に判定できる項目はスクリプトで検出し、判断が要る項目だけ目視で見る。 +共通の運用ルールは CLAUDE.md を正とする。 **検出して報告するだけで、勝手に直さない。** 修正は内容を確認してもらってから。 ## 1. スクリプトを走らせる @@ -27,6 +28,8 @@ ERROR があれば終了コード 1。存在しない引数のファイルもERR | ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない | | ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない | | ERROR | `## 未確認事項` のIDが重複している | +| ERROR | 未確認事項へのローカルリンクの参照先ファイル・見出しがない、または個別項目へのリンクが未確認事項の項目を指していない | +| ERROR | リンクラベルの課題番号と参照先の課題番号が一致しない | | ERROR | 仕様書の `## 保留課題` がリンク箇条書き以外を含む、空である、参照先ファイル・見出しがない | | ERROR | 末尾の管理セクションが重複する、保留課題 → 検討済み・非採用 → 未確認事項の順でない | | ERROR | backlogの項目IDが不正・検査対象内で重複する、必須5欄がない・空である | @@ -35,6 +38,7 @@ ERROR があれば終了コード 1。存在しない引数のファイルもERR | ERROR | `## 検討済み・非採用` の項目が journal ファイル名を参照していない | | ERROR | journal のファイル名が `YYYY-MM-DD-.md` 形式でない | | WARN | `## 検討済み・非採用` が5件を超えた | +| WARN | 本文の段落・箇条書き項目に、リンクのない課題番号や、課題への参照を伴わない「未決」「未定」がある | | WARN | journal の冒頭20行に結論が見当たらない | | WARN | メインドキュメントに比較表・優先度評価・却下案の記述らしきもの | @@ -46,6 +50,11 @@ backlogでは `### PHY-P-14 題名` のように区分付きIDを使い、関連 backlogは短い候補・制約を保持できるため、メインドキュメント向けの散文ヒューリスティックは適用しない。 WARN の散文検出は当たりが粗い。取りこぼしも誤検出もあるので、出た/出ないを結論にしない。 +未確認事項リンクの検査対象は `[P-3](#p-3-...)`・`[MAPのP-3](MAPレイヤー.md#p-3-...)` 等の +インラインリンク。Markdown全般のリンク検査ではない。 +「未決」「未定」のWARNは、次行の参照も同じ段落として扱う。コード例、末尾の管理セクション、 +journal、backlog、ルールブック自体はこのWARNの対象外とする。 +WARNから課題の追加や本文の削除を自動決定しない。 ## 2. スクリプトで見られない項目を確認する @@ -59,6 +68,15 @@ WARN の散文検出は当たりが粗い。取りこぼしも誤検出もある 機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない 記述を探し、`[推測:P-n]` が付いているか確認する。 - **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。 +- **他の担当の説明を移した結果、責務の境界まで消えていないか。** + この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。 +- **非採用一覧の各項目は、将来のどんな再提案を防ぐか。** + 本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。 + 件数・古さ・重複だけで一律に追加・削除しない。 +- **本文の未決記述は、読者に何を伝えるためにあるか。** + 現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。 + 対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。 + リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。 - **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。 - **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。 - **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。 @@ -68,7 +86,9 @@ WARN の散文検出は当たりが粗い。取りこぼしも誤検出もある ## 3. 報告する -ERROR / WARN / 目視で見つけたもの を分けて出す。 +機械検査のERROR / WARNと、目視で確認した内容・未確認の範囲を分けて報告する。 +機械検査が0件でも、内容の分類・責務の境界・記録の必要性が適切だった証明にはしない。 +目視確認を省いた場合はその旨を明記する。 修正案は出してよいが、適用は確認を取ってから。事実関係の誤りを見つけた場合も、直さず指摘する。 ## 注意 diff --git a/.claude/skills/doc-lint/doc-lint.py b/.claude/skills/doc-lint/doc-lint.py index 704e0d6..fa7e4b7 100644 --- a/.claude/skills/doc-lint/doc-lint.py +++ b/.claude/skills/doc-lint/doc-lint.py @@ -17,6 +17,11 @@ 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[^)]*)\)") +INLINE_LINK = re.compile(r"(?\n]+>|[^\s)]+)\s*\)") +INLINE_CODE = re.compile(r"(\x60+).*?\1", re.S) +ISSUE_ID = re.compile(r"(?= fence[1] and not marker.group(2).strip()): + fence = None + continue + if marker: + fence = (marker.group(1)[0], len(marker.group(1))) out.append((i, "")) continue - out.append((i, "" if fence else l)) + out.append((i, l)) return out def section(lines, title): @@ -71,6 +83,108 @@ def local_target(path, target): return linked, unquote(parsed.fragment) +def heading_index(lines): + """ATX見出しのアンカーを作る。同じ見出しの連番も扱う。""" + headings = {} + for n, line in lines: + match = HEADING.match(line) + if not match: + continue + title = match.group(1) + slug = re.sub(r"[^\w\s-]", "", title).lower().replace(" ", "-") + anchor, suffix = slug, 0 + while anchor in headings: + suffix += 1 + anchor = f"{slug}-{suffix}" + headings[anchor] = (n, title) + return headings + + +def body_paragraphs(lines): + """改行をまたぐ本文段落を返す。見出し・空行・次の箇条書きで区切る。""" + paragraph = [] + in_management = False + for n, line in lines: + if HEADING.match(line): + if paragraph: + yield paragraph + paragraph = [] + h2 = H2.match(line) + if h2: + in_management = h2.group(1) in MANAGEMENT_SECTIONS + elif line.startswith("# "): + in_management = False + continue + if in_management: + continue + if not line.strip() or re.match(r"^\s*(?:[-*+]|\d+[.)])\s+", line): + if paragraph: + yield paragraph + paragraph = [] + if line.strip(): + paragraph.append((n, line)) + if paragraph: + yield paragraph + + +def check_issue_references(path, rel, lines): + """課題リンクの構造はERROR、本文の参照漏れの候補はWARNにする。""" + targets = {} + valid_link_lines = set() + # インラインコードも位置を変えずにマスクする。 + text = "\n".join(line for _, line in lines) + masked = INLINE_CODE.sub(lambda m: re.sub(r"[^\n]", " ", m.group()), text).split("\n") + prose_lines = [(n, line) for (n, _), line in zip(lines, masked)] + for n, line in prose_lines: + for match in INLINE_LINK.finditer(line): + label, target = match.groups() + target = target.strip("<>") + linked, anchor = local_target(path, target) + if linked is None: + continue + label_ids = ISSUE_ID.findall(label) + if not (label_ids or ISSUE_ANCHOR.match(anchor) or anchor == "未確認事項"): + continue + if not linked.is_file(): + add("ERROR", rel, n, "未確認事項リンクの参照先ファイルがない: %s" % target) + continue + if linked not in targets: + target_lines = load(linked) + entries = { + ln: ENTRY.match(value).group(1) + for ln, value in (section(target_lines, "未確認事項") or []) + if ENTRY.match(value) + } + targets[linked] = (heading_index(target_lines), entries) + headings, entries = targets[linked] + if not anchor or anchor not in headings: + add("ERROR", rel, n, "未確認事項リンクの参照先見出しがない: %s" % target) + continue + target_line, _ = headings[anchor] + if anchor == "未確認事項" and not label_ids: + continue + target_id = entries.get(target_line) + if target_id is None: + add("ERROR", rel, n, "課題リンクが未確認事項の個別項目を指していない: %s" % target) + elif any(pid != target_id for pid in label_ids): + add("ERROR", rel, n, "リンクラベルの課題番号と参照先が一致しない: %s → %s" % (label, target_id)) + else: + valid_link_lines.add(n) + + for paragraph in body_paragraphs(prose_lines): + n = paragraph[0][0] + text = "\n".join(line for _, line in paragraph) + # リンクと推測タグには別の構造検査がある。裸の課題番号だけを探す。 + plain = TAG.sub("", INLINE_LINK.sub("", text)) + bare_ids = sorted(set(ISSUE_ID.findall(plain))) + if bare_ids: + add("WARN", rel, n, "本文にリンクのない課題番号がある(要確認): %s" % ", ".join(bare_ids)) + if (re.search(r"未決|未定", plain) + and not any(ln in valid_link_lines for ln, _ in paragraph) + and not TAG.search(text)): + add("WARN", rel, n, "本文の未決・未定に課題への参照がない(要確認)。リンク・課題追加・言及削除を判断する") + + def check_backlog(path, rel, lines, seen_ids): blocks = [] current = None @@ -138,12 +252,7 @@ def check_backlog_section(path, rel, lines): 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) + headings = heading_index(load(linked)) if anchor not in headings: add("ERROR", rel, n, "保留課題の参照先見出しがない: %s" % target) @@ -160,6 +269,8 @@ def check(path, seen_backlog_ids=None): 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) + if base not in PROSE_EXEMPT: + check_issue_references(path, rel, lines) unconf = section(lines, "未確認事項") rejected = section(lines, "検討済み・非採用") @@ -217,7 +328,7 @@ def check(path, seen_backlog_ids=None): "検討済み・非採用の項目が journal ファイル名を参照していない") if len(items) > 5: add("WARN", rel, items[5][0], - "検討済み・非採用が %d 件(目安5件)。古いものから落とす" % len(items)) + "検討済み・非採用が %d 件(目安5件)。再提案を防ぐ必要性で掲載を見直す" % len(items)) # --- journal 固有 --- if is_journal: diff --git a/.claude/skills/doc-lint/test_doc_lint.py b/.claude/skills/doc-lint/test_doc_lint.py index dedb275..e34ee3e 100644 --- a/.claude/skills/doc-lint/test_doc_lint.py +++ b/.claude/skills/doc-lint/test_doc_lint.py @@ -5,6 +5,7 @@ import io import tempfile import unittest from pathlib import Path +from urllib.parse import quote from unittest.mock import patch @@ -24,6 +25,14 @@ def backlog_item(pid="PHY-P-14"): ) +def issue_section(): + return ( + "## 未確認事項\n\n" + "### P-1 動作の範囲\n\n- 完了条件: 範囲を決める\n\n" + "### P-3 対応・状態管理\n\n- 完了条件: 管理方法を決める\n" + ) + + class DocLintTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() @@ -145,6 +154,147 @@ class DocLintTests(unittest.TestCase): self.assertEqual(status, 1) self.assertIn("対象ファイルが見つからない", output) + def test_issue_links_accept_same_file_cross_file_and_encoded_anchors(self): + self.write("MAPレイヤー.md", "# MAP\n\n" + issue_section()) + encoded = quote("MAPレイヤー.md") + "#" + quote("p-3-対応状態管理") + path = self.write( + "PHY.md", + "# PHY\n\n範囲は未決。\n[P-1](#p-1-動作の範囲)で扱う。\n\n" + "[MAPのP-3](MAPレイヤー.md#p-3-対応状態管理)へ。\n\n" + f"[MAPのP-3]({encoded})へ。\n\n" + issue_section(), + ) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("ERROR 0 / WARN 0", output) + + def test_issue_links_reject_missing_file_heading_and_fragment(self): + self.write("MAP.md", "# MAP\n\n" + issue_section()) + for target, expected in ( + ("missing.md#p-3-対応状態管理", "参照先ファイルがない"), + ("MAP.md#p-3-古い題名", "参照先見出しがない"), + ("MAP.md", "参照先見出しがない"), + ): + with self.subTest(target=target): + path = self.write("PHY.md", f"[P-3]({target})\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn(expected, output) + + def test_issue_label_must_match_real_entry_in_unconfirmed_section(self): + self.write("MAP.md", "# MAP\n\n### P-2 本文の見出し\n\n" + issue_section()) + for link, expected in ( + ("[P-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"), + ("[MAPのP-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"), + ("[P-2](MAP.md#p-2-本文の見出し)", "個別項目を指していない"), + ("[P-3](MAP.md#未確認事項)", "個別項目を指していない"), + ): + with self.subTest(link=link): + path = self.write("PHY.md", link + "\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn(expected, output) + + def test_duplicate_heading_slug_resolves_actual_target(self): + self.write("MAP.md", "# MAP\n\n### P-1 動作の範囲\n\n" + issue_section()) + path = self.write("PHY.md", "[P-1](MAP.md#p-1-動作の範囲-1)\n") + self.assertEqual(self.run_lint(path)[0], 0) + path.write_text("[P-1](MAP.md#p-1-動作の範囲)\n", encoding="utf-8") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn("個別項目を指していない", output) + + def test_unresolved_prose_warns_without_failing_or_modifying_documents(self): + text = "# PHY\n\n方式は未定。P-3で検討する。\n\n" + issue_section() + path = self.write("PHY.md", text) + before = path.read_bytes() + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("リンクのない課題番号", output) + self.assertIn("未決・未定に課題への参照がない", output) + self.assertEqual(path.read_bytes(), before) + + def test_another_issue_link_does_not_hide_bare_id(self): + path = self.write( + "PHY.md", "P-3と[P-1](#p-1-動作の範囲)を参照。\n\n" + issue_section() + ) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("リンクのない課題番号がある(要確認): P-3", output) + + def test_unrelated_links_and_section_links_do_not_hide_unresolved_prose(self): + for link in ("[仕様](PHY.md)", "[一覧](#未確認事項)", "[外部](https://example.com/)"): + with self.subTest(link=link): + path = self.write("PHY.md", f"範囲は未決。{link}\n\n" + issue_section()) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("未決・未定に課題への参照がない", output) + + def test_broken_link_does_not_count_as_resolved_reference(self): + path = self.write("PHY.md", "範囲は未決。\n[P-1](#p-1-missing)\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn("参照先見出しがない", output) + self.assertIn("未決・未定に課題への参照がない", output) + + def test_paragraph_and_list_boundaries_do_not_borrow_links(self): + for body in ( + "範囲は未決。\n\n[P-1](#p-1-動作の範囲)\n", + "- 範囲は未決。\n- [P-1](#p-1-動作の範囲)\n", + "1. 範囲は未決。\n2. [P-1](#p-1-動作の範囲)\n", + "範囲は未決。\n## 次の話\n[P-1](#p-1-動作の範囲)\n", + ): + with self.subTest(body=body): + path = self.write("PHY.md", body + "\n" + issue_section()) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("未決・未定に課題への参照がない", output) + path = self.write( + "PHY.md", "- 範囲は未決。\n [P-1](#p-1-動作の範囲)\n\n" + issue_section() + ) + self.assertIn("ERROR 0 / WARN 0", self.run_lint(path)[1]) + + def test_code_examples_external_links_and_assumptions_are_not_prose_warnings(self): + path = self.write( + "PHY.md", + "\x60未決 P-8 [P-8](#missing)\x60\n\n" + "\x60\x60\x60markdown\n未定 P-8 [P-8](#missing)\n\x60\x60\x60\n\n" + "~~~markdown\n未定 P-8 [P-8](#missing)\n~~~\n\n" + "[P-99](https://example.com/#p-99)\n\n" + "範囲は未定 [推測:P-1]。\n\n" + "## 未確認事項\n\n### P-1 調査\n\n- 格上げ条件: 検証する\n", + ) + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("ERROR 0 / WARN 0", output) + + def test_management_sections_and_non_spec_files_are_exempt_from_prose_warnings(self): + self.write("CLAUDE.md", "未決 P-8 [P-8](#missing)\n") + self.write("journal/2026-09-22-decision.md", "## 結論\n\n未定 P-8\n") + self.write("backlog/README.md", "## PHY\n\n" + backlog_item() + "\n未定 P-8\n") + self.write("PHY.md", "# PHY\n\n" + issue_section() + "\n未定 P-8\n") + status, output = self.run_lint() + self.assertEqual(status, 0, output) + self.assertIn("WARN 0", output) + + def test_links_inside_unconfirmed_items_are_still_checked(self): + path = self.write("PHY.md", issue_section() + "\n[P-8](#missing)\n") + status, output = self.run_lint(path) + self.assertEqual(status, 1, output) + self.assertIn("参照先見出しがない", output) + + def test_rejected_count_warning_does_not_select_entries_for_deletion(self): + path = self.write( + "PHY.md", "## 検討済み・非採用\n\n" + + "".join(f"- 案{i} (journal/2026-09-22-decision.md)\n" for i in range(6)) + ) + before = path.read_bytes() + status, output = self.run_lint(path) + self.assertEqual(status, 0, output) + self.assertIn("6 件(目安5件)", output) + self.assertIn("再提案を防ぐ必要性", output) + self.assertNotIn("古いものから落とす", output) + self.assertEqual(path.read_bytes(), before) + if __name__ == "__main__": unittest.main()