agent: decide/doc-lintを更新し、.agents と .claude を同期

codexが .agents 側で更新した内容を .claude へ取り込み、名前依存の箇所だけ
置換した。両ツリーの差分はCLAUDE.md/AGENTS.mdとパスの表記のみ。

- 非採用一覧の掲載基準をルール1の変更に合わせる
- 本文の「未決」はリンク・課題追加・言及削除のいずれかを判断する(ルール4)
- 責務を外へ移したあと、担当範囲が分かる一文と参照が残っているか確認する
- doc-lintに未確認事項リンクの参照先・ラベル整合の検査(ERROR)と、
  リンクのない課題番号・参照なしの「未決」「未定」の検出(WARN)を追加
- load() のコードフェンス処理が ~~~ や長さ違いを扱えていなかったのを修正
- .Codex という実在しないパスの参照を .agents に修正

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtVk8W4yK6j3eCqQpEQBLS
This commit is contained in:
2026-09-22 22:32:18 +09:00
co-authored by Claude Opus 5
parent 2fa910ed74
commit 54f292b08e
8 changed files with 623 additions and 39 deletions
+17 -6
View File
@@ -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決定。
+21 -1
View File
@@ -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-<topic>.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件でも、内容の分類・責務の境界・記録の必要性が適切だった証明にはしない。
目視確認を省いた場合はその旨を明記する。
修正案は出してよいが、適用は確認を取ってから。事実関係の誤りを見つけた場合も、直さず指摘する。
## 注意
+122 -11
View File
@@ -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*(<[^>\n]+>|[^\s)]+)\s*\)")
INLINE_CODE = re.compile(r"(\x60+).*?\1", re.S)
ISSUE_ID = re.compile(r"(?<![A-Za-z0-9_-])P-\d+(?![A-Za-z0-9_-])")
ISSUE_ANCHOR = re.compile(r"^p-\d+(?:-|$)")
HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$")
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")
@@ -36,13 +41,20 @@ def add(sev, path, line, msg):
def load(path):
"""コードフェンス内を空行に潰した (行番号, 本文) の列を返す。"""
raw = Path(path).read_text(encoding="utf-8").splitlines()
out, fence = [], False
out, fence = [], None
for i, l in enumerate(raw, 1):
if l.lstrip().startswith("```"):
fence = not fence
marker = re.match(r"^ {0,3}(\x60{3,}|~{3,})(.*)$", l)
if fence is not None:
out.append((i, ""))
if (marker and marker.group(1)[0] == fence[0]
and len(marker.group(1)) >= 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:
+150
View File
@@ -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()