docsweep 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. docsweep/__init__.py +3 -0
  2. docsweep/__main__.py +4 -0
  3. docsweep/activity.py +183 -0
  4. docsweep/aggregate_index.py +148 -0
  5. docsweep/archive.py +80 -0
  6. docsweep/atomic.py +181 -0
  7. docsweep/auto_triage.py +172 -0
  8. docsweep/brief/__init__.py +9 -0
  9. docsweep/brief/score.py +132 -0
  10. docsweep/brief/service.py +243 -0
  11. docsweep/capture/__init__.py +15 -0
  12. docsweep/capture/heuristics.py +69 -0
  13. docsweep/capture/llm.py +152 -0
  14. docsweep/capture/models.py +29 -0
  15. docsweep/capture/service.py +79 -0
  16. docsweep/claim.py +112 -0
  17. docsweep/cli.py +1771 -0
  18. docsweep/completion.py +157 -0
  19. docsweep/config.py +418 -0
  20. docsweep/context.py +160 -0
  21. docsweep/cross/__init__.py +11 -0
  22. docsweep/cross/service.py +207 -0
  23. docsweep/detect.py +328 -0
  24. docsweep/engine.py +312 -0
  25. docsweep/export.py +287 -0
  26. docsweep/find.py +133 -0
  27. docsweep/graph/__init__.py +8 -0
  28. docsweep/graph/service.py +106 -0
  29. docsweep/index.py +521 -0
  30. docsweep/inject.py +654 -0
  31. docsweep/interactive.py +344 -0
  32. docsweep/linkcheck.py +231 -0
  33. docsweep/mcp_server.py +471 -0
  34. docsweep/migrate.py +167 -0
  35. docsweep/models.py +94 -0
  36. docsweep/presets.py +56 -0
  37. docsweep/related.py +175 -0
  38. docsweep/reports.py +315 -0
  39. docsweep/resurrect/__init__.py +12 -0
  40. docsweep/resurrect/embedding.py +43 -0
  41. docsweep/resurrect/service.py +184 -0
  42. docsweep/resurrect/similarity.py +38 -0
  43. docsweep/review.py +69 -0
  44. docsweep/scan.py +431 -0
  45. docsweep/security/__init__.py +13 -0
  46. docsweep/security/path.py +65 -0
  47. docsweep/server/__init__.py +5 -0
  48. docsweep/server/app.py +319 -0
  49. docsweep/server/routes/__init__.py +11 -0
  50. docsweep/server/routes/board.py +456 -0
  51. docsweep/server/routes/brief.py +66 -0
  52. docsweep/server/routes/capture.py +108 -0
  53. docsweep/server/routes/cards.py +436 -0
  54. docsweep/server/routes/cross.py +62 -0
  55. docsweep/server/routes/graph.py +54 -0
  56. docsweep/server/routes/resurrect.py +64 -0
  57. docsweep/server/sanitize.py +144 -0
  58. docsweep/server/security.py +47 -0
  59. docsweep/server/static/board.css +640 -0
  60. docsweep/server/static/dnd.js +116 -0
  61. docsweep/server/static/edit.js +258 -0
  62. docsweep/server/static/htmx.min.js +1 -0
  63. docsweep/server/static/icons/apple-touch-icon.png +0 -0
  64. docsweep/server/static/icons/favicon.ico +0 -0
  65. docsweep/server/static/icons/favicon.svg +30 -0
  66. docsweep/server/static/icons/icon-192.png +0 -0
  67. docsweep/server/static/icons/icon-512.png +0 -0
  68. docsweep/server/static/keymap.js +1123 -0
  69. docsweep/server/templates/_board_body.html +102 -0
  70. docsweep/server/templates/_card.html +68 -0
  71. docsweep/server/templates/_change_picker.html +27 -0
  72. docsweep/server/templates/_due_picker.html +13 -0
  73. docsweep/server/templates/_edit_pane.html +73 -0
  74. docsweep/server/templates/_label_picker.html +14 -0
  75. docsweep/server/templates/_preview.html +15 -0
  76. docsweep/server/templates/_settings.html +68 -0
  77. docsweep/server/templates/board.html +111 -0
  78. docsweep/server/templates/brief.html +89 -0
  79. docsweep/server/templates/capture.html +121 -0
  80. docsweep/server/templates/cross.html +74 -0
  81. docsweep/server/templates/graph.html +99 -0
  82. docsweep/server/templates/resurrect.html +58 -0
  83. docsweep/services/__init__.py +11 -0
  84. docsweep/services/archive.py +240 -0
  85. docsweep/services/content.py +68 -0
  86. docsweep/services/due.py +189 -0
  87. docsweep/services/frontmatter.py +241 -0
  88. docsweep/services/status.py +157 -0
  89. docsweep/stale.py +117 -0
  90. docsweep/state.py +198 -0
  91. docsweep/states.py +165 -0
  92. docsweep/templates_gen.py +178 -0
  93. docsweep/timeline.py +177 -0
  94. docsweep-0.1.0.dist-info/METADATA +484 -0
  95. docsweep-0.1.0.dist-info/RECORD +99 -0
  96. docsweep-0.1.0.dist-info/WHEEL +4 -0
  97. docsweep-0.1.0.dist-info/entry_points.txt +2 -0
  98. docsweep-0.1.0.dist-info/licenses/LICENSE +21 -0
  99. docsweep-0.1.0.dist-info/licenses/NOTICES.md +47 -0
@@ -0,0 +1,172 @@
1
+ """C5: auto-triage — md の状態遷移提案を LLM に委譲し、承認済みを一括適用する。
2
+
3
+ 設計:
4
+ - ``suggest`` モード: md 本文 + 索引メタ + linkcheck 結果 を LLM に渡して
5
+ 「[完了] / 維持 / [廃止] / 不明」と根拠を返してもらう
6
+ - ``apply`` モード: 提案を ``engine.apply_action`` で一括実行する
7
+
8
+ 実 LLM は本 plan では呼ばない。``ruleset`` ベースの decider(フラグから推測)と
9
+ Mock LLM 経路の両方を用意する。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import asdict, dataclass, field
15
+
16
+ from .config import Config
17
+ from .engine import apply_action, run_scan
18
+ from .linkcheck import linkcheck
19
+ from .models import Action, FileRecord, Flag
20
+
21
+
22
+ @dataclass
23
+ class TriageSuggestion:
24
+ """1 ファイル分の遷移提案。"""
25
+
26
+ path: str
27
+ project: str
28
+ current_state: str | None
29
+ proposed_action: str # "discard" / "keep" / "resume" / "relabel" / "promote" / "skip"
30
+ proposed_to: str | None # relabel 時の宛先ラベル名(例 "[完了]")
31
+ reason: str
32
+ confidence: float # 0..1
33
+
34
+ def to_dict(self) -> dict:
35
+ return asdict(self)
36
+
37
+
38
+ @dataclass
39
+ class TriageSuggestResult:
40
+ suggestions: list[TriageSuggestion] = field(default_factory=list)
41
+
42
+ def to_dict(self) -> dict:
43
+ return {"suggestions": [s.to_dict() for s in self.suggestions]}
44
+
45
+
46
+ def _ruleset_decide(rec: FileRecord, lc_progress: str | None) -> TriageSuggestion | None:
47
+ """ヒューリスティック decider。LLM 不要の最小実装。
48
+
49
+ 判定ルール:
50
+ - linkcheck progress = "implemented" の plan → "promote" (完了候補)
51
+ - NEEDS_DECISION + age > 180 → "discard" 候補(陳腐化が長すぎる)
52
+ - state=watching + age > 14 → "promote" (release sweep 候補)
53
+ - それ以外 → 提案無し
54
+ """
55
+ flags = set(rec.flags or [])
56
+ age = rec.age_days or 0
57
+
58
+ if rec.type == "plan" and lc_progress == "implemented" and rec.state in {"planned", "in-progress"}:
59
+ return TriageSuggestion(
60
+ path=rec.path, project=rec.project, current_state=rec.state,
61
+ proposed_action="relabel",
62
+ proposed_to="[完了]",
63
+ reason="linkcheck で「変更予定ファイル」がほぼ実装済み + commit 言及あり",
64
+ confidence=0.75,
65
+ )
66
+
67
+ if Flag.NEEDS_DECISION.value in flags and age > 180:
68
+ return TriageSuggestion(
69
+ path=rec.path, project=rec.project, current_state=rec.state,
70
+ proposed_action="discard",
71
+ proposed_to=None,
72
+ reason=f"陳腐化フラグが立ってから 180 日超 (age={age}d) — 廃止判断を提案",
73
+ confidence=0.6,
74
+ )
75
+
76
+ if rec.state == "watching" and age > 14:
77
+ return TriageSuggestion(
78
+ path=rec.path, project=rec.project, current_state=rec.state,
79
+ proposed_action="promote",
80
+ proposed_to=None,
81
+ reason=f"様子見 → 完了 へ昇格候補 (age={age}d, release sweep に該当)",
82
+ confidence=0.5,
83
+ )
84
+
85
+ return None
86
+
87
+
88
+ def suggest_transitions(
89
+ config: Config, *, target: str | None = None,
90
+ ) -> TriageSuggestResult:
91
+ """状態遷移提案を生成する。
92
+
93
+ Args:
94
+ config: ロード済み Config
95
+ target: 単一ファイルの相対パス / basename。None で全件
96
+
97
+ Returns:
98
+ ``TriageSuggestResult``。
99
+ """
100
+ # linkcheck 結果を map にまとめておく(plan 進捗を判断材料に)
101
+ lc_map: dict[str, str] = {}
102
+ for lc in linkcheck(config):
103
+ lc_map[lc.plan_path] = lc.progress_hint
104
+
105
+ result = run_scan(config)
106
+ suggestions: list[TriageSuggestion] = []
107
+ for doc in result.docs:
108
+ rec = doc.record
109
+ if target:
110
+ from pathlib import Path
111
+ t = Path(target)
112
+ if rec.path != str(t) and Path(rec.path).name != t.name:
113
+ continue
114
+ s = _ruleset_decide(rec, lc_map.get(rec.path))
115
+ if s is not None:
116
+ suggestions.append(s)
117
+ return TriageSuggestResult(suggestions=suggestions)
118
+
119
+
120
+ @dataclass
121
+ class ApplyResult:
122
+ applied: list[dict] = field(default_factory=list)
123
+ skipped: list[dict] = field(default_factory=list)
124
+ failed: list[dict] = field(default_factory=list)
125
+
126
+ def to_dict(self) -> dict:
127
+ return asdict(self)
128
+
129
+
130
+ def apply_suggestions(
131
+ config: Config,
132
+ decisions: list[dict],
133
+ *,
134
+ dry_run: bool = False,
135
+ ) -> ApplyResult:
136
+ """承認済み提案を一括適用する。
137
+
138
+ Args:
139
+ config: ロード済み Config
140
+ decisions: ``[{path, action, to?}]`` のリスト。CLI / MCP で承認したものを渡す
141
+ dry_run: True で実際の移送/書換は行わず計画だけ返す
142
+
143
+ Returns:
144
+ ``ApplyResult``。
145
+ """
146
+ result = run_scan(config)
147
+ by_path = {d.record.path: d for d in result.docs}
148
+ applied: list[dict] = []
149
+ skipped: list[dict] = []
150
+ failed: list[dict] = []
151
+
152
+ for d in decisions:
153
+ path = d.get("path")
154
+ action = d.get("action")
155
+ to = d.get("to")
156
+ if not path or not action:
157
+ failed.append({"path": path, "reason": "path/action 欠落"})
158
+ continue
159
+ doc = by_path.get(path)
160
+ if doc is None:
161
+ failed.append({"path": path, "reason": "対象ファイルが見つからない"})
162
+ continue
163
+ if action == "skip":
164
+ skipped.append({"path": path})
165
+ continue
166
+ try:
167
+ entry = apply_action(doc, action, config, to=to, dry_run=dry_run)
168
+ applied.append(entry.to_dict())
169
+ except ValueError as e:
170
+ failed.append({"path": path, "reason": str(e)})
171
+
172
+ return ApplyResult(applied=applied, skipped=skipped, failed=failed)
@@ -0,0 +1,9 @@
1
+ """C3 (wings): brief — 「今日の 1 個」を断定する出口の双子(CLI / Web 共通基盤)。
2
+
3
+ 主役は :func:`service.build_brief` と :func:`score.score_record`。CLI/Web/MCP は全部この
4
+ 2 関数を経由する(再実装しない)。
5
+ """
6
+
7
+ from .service import BriefResult, build_brief
8
+
9
+ __all__ = ["BriefResult", "build_brief"]
@@ -0,0 +1,132 @@
1
+ """brief / cross 共通のスコア式(C3 + C4 共有モジュール)。
2
+
3
+ 「今日の 1 個」を決めるための単一スコア式を 1 か所に集める。式は ``score_record(rec)`` で
4
+ 1 つの FileRecord に対して float を返す。式自体は **断定する** ことを優先しており、
5
+ 同点時はタイブレーカ(``rec.path`` 昇順)でも必ず 1 件が決まる。
6
+
7
+ 設計指針:
8
+ - 入力は ``FileRecord`` のみ(索引から復元された FileRecord でもそのまま動く)
9
+ - 外部 LLM を呼ばない(決定性・速度のため)
10
+ - フィールドの欠落は 0 として扱い、例外を投げない
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from datetime import date
17
+ from pathlib import Path
18
+
19
+ from ..models import FileRecord, Flag
20
+
21
+ # スコア重み — 「今日の 1 個」の意思決定要素ごとの寄与度。値の妥当性はテストで固定する。
22
+ W_URGENCY = 40.0 # due 切れ・要判断(陳腐化)への重み
23
+ W_STALE = 1.5 # 経過日数の寄与(1 日 = 1.5 点)
24
+ W_REVIEW_STATUS = 5.0 # review_status 別の追加点(review > draft > published)
25
+ W_TOUCHED_DECAY = 0.3 # 直近触ったものを下げる(同じファイルに張り付かない誘導)
26
+ W_DEP_CHAIN = 8.0 # related の本数(プロジェクト中枢ファイルを浮かせる)
27
+
28
+ # stale 寄与の上限日数(無制限だと古い凍結ファイルが top_pick を独占してしまう)。
29
+ # 30 日 = 最大 45 点。urgency boost (= NEEDS_DECISION 40 点) と同等のオーダーになる。
30
+ STALE_DAY_CAP = 30
31
+
32
+ # state ごとの基礎点。done / discarded は brief の対象外として 0 にする
33
+ # (これらは archive 候補なので brief には浮かべない)。
34
+ _STATE_BASE: dict[str, float] = {
35
+ "in-progress": 12.0,
36
+ "planned": 10.0,
37
+ "watching": 6.0,
38
+ "pending": 5.0,
39
+ "done": 0.0,
40
+ "discarded": 0.0,
41
+ }
42
+
43
+ _REVIEW_STATUS_WEIGHT: dict[str, float] = {
44
+ "review": 2.0, # 「レビュー待ち」は催促したい
45
+ "draft": 1.0,
46
+ "published": 0.0,
47
+ }
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ScoreBreakdown:
52
+ """スコア内訳。``brief --explain`` / Web で内訳を見せるときに使う。"""
53
+
54
+ total: float
55
+ state_base: float
56
+ urgency: float
57
+ stale: float
58
+ review_status: float
59
+ touched_decay: float
60
+ dep_chain: float
61
+
62
+ def to_dict(self) -> dict:
63
+ return {
64
+ "total": round(self.total, 2),
65
+ "state_base": round(self.state_base, 2),
66
+ "urgency": round(self.urgency, 2),
67
+ "stale": round(self.stale, 2),
68
+ "review_status": round(self.review_status, 2),
69
+ "touched_decay": round(self.touched_decay, 2),
70
+ "dep_chain": round(self.dep_chain, 2),
71
+ }
72
+
73
+
74
+ def _urgency_score(rec: FileRecord, *, today: date | None = None) -> float:
75
+ """due 超過 / NEEDS_DECISION (陳腐化) を urgency 軸として加点する。
76
+
77
+ NEEDS_DECISION は「陳腐化した未終端 = 即判断が要る」を意味する強いシグナル。
78
+ stale (経過日数の連続値) より優先するため、40 点をつける(W_URGENCY 同等)。
79
+ """
80
+ score = 0.0
81
+ base = today or date.today()
82
+ if rec.due:
83
+ try:
84
+ d = date.fromisoformat(rec.due)
85
+ days_over = (base - d).days
86
+ if days_over > 0:
87
+ # 1 日 1 点、上限 +30 まで(青天井にしない)
88
+ score += min(30.0, float(days_over))
89
+ except ValueError:
90
+ pass
91
+ if Flag.NEEDS_DECISION.value in (rec.flags or []):
92
+ score += 40.0 # 強いシグナル: stale cap 寄与より大きく
93
+ if Flag.OVERDUE_TODO.value in (rec.flags or []):
94
+ score += 20.0
95
+ if Flag.OVERDUE_GRADUATE.value in (rec.flags or []):
96
+ score += 12.0
97
+ return score
98
+
99
+
100
+ def score_record(rec: FileRecord, *, today: date | None = None) -> ScoreBreakdown:
101
+ """FileRecord を入力に ``ScoreBreakdown`` を返す。``total`` が大きいほど「今日の 1 個」候補。
102
+
103
+ done / discarded はスコア 0 で固定(brief / cross の対象外)。
104
+ """
105
+ state_base = _STATE_BASE.get(rec.state or "", 0.0)
106
+ if state_base == 0.0 and rec.state in {"done", "discarded"}:
107
+ return ScoreBreakdown(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
108
+
109
+ urgency = _urgency_score(rec, today=today) * (W_URGENCY / 40.0)
110
+ # stale は cap 付き(古い凍結ファイルが top_pick を独占しないよう抑える)。
111
+ capped_age = min(float(rec.age_days or 0), float(STALE_DAY_CAP))
112
+ stale = capped_age * W_STALE
113
+ review_status = _REVIEW_STATUS_WEIGHT.get(rec.review_status or "", 0.0) * W_REVIEW_STATUS
114
+ # 触ったばかりのファイルは下げる(同じものに張り付かない誘導)
115
+ touched_decay = max(0.0, 14.0 - float(rec.age_days or 0)) * W_TOUCHED_DECAY
116
+ dep_chain = float(len(rec.related or [])) * W_DEP_CHAIN
117
+
118
+ total = state_base + urgency + stale + review_status - touched_decay + dep_chain
119
+ return ScoreBreakdown(
120
+ total=total,
121
+ state_base=state_base,
122
+ urgency=urgency,
123
+ stale=stale,
124
+ review_status=review_status,
125
+ touched_decay=-touched_decay,
126
+ dep_chain=dep_chain,
127
+ )
128
+
129
+
130
+ def tiebreak_key(rec: FileRecord) -> tuple:
131
+ """同点時のタイブレーカ。決定性を確保するため ``path`` 昇順で 1 件に絞る。"""
132
+ return (rec.project or "", Path(rec.path).name, rec.path)
@@ -0,0 +1,243 @@
1
+ """C3: brief 生成のコアロジック。CLI / Web / MCP がすべてここを呼ぶ。
2
+
3
+ brief は「今日 1 個だけやろう」を断定するための朝の入口。表示要素は 4 つ:
4
+
5
+ 1. ``today_pick``: 「今日の 1 個」(最高スコア・必ず 1 件決まる)
6
+ 2. ``co_running``: 併走中(in-progress 上位 ~3 件、today_pick を除く)
7
+ 3. ``watchouts``: 要注意 stale(NEEDS_DECISION / OVERDUE_TODO 等を持つ古い未終端)
8
+ 4. ``yesterday_done``: 昨日終わったこと(mtime が 24h 以内の done/discarded)
9
+
10
+ すべてプロジェクト粒度の概念で、``--project all`` 時は project_id ごとに同じ束を作る。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import asdict, dataclass, field
16
+ from datetime import date, datetime, timedelta, timezone
17
+ from pathlib import Path
18
+
19
+ from ..config import Config
20
+ from ..engine import scan_records
21
+ from ..models import FileRecord, Flag
22
+ from .score import ScoreBreakdown, score_record, tiebreak_key
23
+
24
+
25
+ def _short_record(rec: FileRecord, score: ScoreBreakdown | None = None) -> dict:
26
+ """brief 表示で使う slim 表現。冗長な path は basename を併記して人間にも AI にも読める形に。"""
27
+ out = {
28
+ "path": rec.path,
29
+ "rel": Path(rec.path).name,
30
+ "project": rec.project,
31
+ "type": rec.type,
32
+ "state": rec.state,
33
+ "state_label": rec.state_label,
34
+ "title": rec.title,
35
+ "summary": rec.summary,
36
+ "age_days": rec.age_days,
37
+ "due": rec.due,
38
+ "owner": rec.owner,
39
+ "flags": list(rec.flags or []),
40
+ "tags": list(rec.tags or []),
41
+ }
42
+ if score is not None:
43
+ out["score"] = score.to_dict()
44
+ return out
45
+
46
+
47
+ @dataclass
48
+ class ProjectBrief:
49
+ """1 プロジェクト分の brief。``BriefResult.projects`` に複数並ぶ。"""
50
+
51
+ project: str
52
+ today_pick: dict | None
53
+ co_running: list[dict] = field(default_factory=list)
54
+ watchouts: list[dict] = field(default_factory=list)
55
+ yesterday_done: list[dict] = field(default_factory=list)
56
+ open_count: int = 0
57
+ stale_count: int = 0
58
+
59
+ def to_dict(self) -> dict:
60
+ return asdict(self)
61
+
62
+
63
+ @dataclass
64
+ class BriefResult:
65
+ """brief の最終出力。CLI/Web/MCP 共通。"""
66
+
67
+ mode: str # "single" | "all"
68
+ generated_at: str
69
+ projects: list[ProjectBrief] = field(default_factory=list)
70
+
71
+ def to_dict(self) -> dict:
72
+ return {
73
+ "mode": self.mode,
74
+ "generated_at": self.generated_at,
75
+ "projects": [p.to_dict() for p in self.projects],
76
+ }
77
+
78
+
79
+ def _is_open_state(rec: FileRecord) -> bool:
80
+ """brief の主要対象(done/discarded 以外の未終端)。"""
81
+ return rec.state in {"in-progress", "planned", "watching", "pending"}
82
+
83
+
84
+ def _yesterday_window(now: datetime) -> tuple[float, float]:
85
+ """直近 24h ウィンドウの mtime 範囲(epoch 秒)。"""
86
+ end = now.timestamp()
87
+ start = (now - timedelta(hours=24)).timestamp()
88
+ return start, end
89
+
90
+
91
+ def _build_for_project(
92
+ records: list[FileRecord], project: str, *, today: date, now: datetime
93
+ ) -> ProjectBrief:
94
+ """1 プロジェクト分のレコードから ProjectBrief を組み立てる。"""
95
+ open_recs = [r for r in records if _is_open_state(r)]
96
+ scored: list[tuple[FileRecord, ScoreBreakdown]] = sorted(
97
+ ((r, score_record(r, today=today)) for r in open_recs),
98
+ key=lambda pair: (-pair[1].total, tiebreak_key(pair[0])),
99
+ )
100
+
101
+ today_pick: dict | None = None
102
+ co_running: list[dict] = []
103
+ if scored:
104
+ head_rec, head_score = scored[0]
105
+ today_pick = _short_record(head_rec, head_score)
106
+ for rec, sc in scored[1:4]:
107
+ if rec.state == "in-progress" or len(co_running) < 2:
108
+ co_running.append(_short_record(rec, sc))
109
+ if len(co_running) >= 3:
110
+ break
111
+
112
+ watchouts: list[dict] = []
113
+ for rec in open_recs:
114
+ flags = set(rec.flags or [])
115
+ if not (flags & {
116
+ Flag.NEEDS_DECISION.value,
117
+ Flag.OVERDUE_TODO.value,
118
+ Flag.OVERDUE_GRADUATE.value,
119
+ }):
120
+ continue
121
+ if today_pick and rec.path == today_pick["path"]:
122
+ continue
123
+ watchouts.append(_short_record(rec, score_record(rec, today=today)))
124
+ watchouts.sort(key=lambda d: -(d.get("score") or {}).get("total", 0.0))
125
+ watchouts = watchouts[:5]
126
+
127
+ start, end = _yesterday_window(now)
128
+ yesterday: list[dict] = []
129
+ for rec in records:
130
+ if rec.state not in {"done", "discarded"}:
131
+ continue
132
+ if rec.mtime and start <= rec.mtime <= end:
133
+ yesterday.append(_short_record(rec))
134
+ yesterday.sort(key=lambda d: -(d.get("age_days") or 0))
135
+ yesterday = yesterday[:5]
136
+
137
+ stale_count = sum(1 for r in open_recs if Flag.STALE.value in (r.flags or []))
138
+
139
+ return ProjectBrief(
140
+ project=project,
141
+ today_pick=today_pick,
142
+ co_running=co_running,
143
+ watchouts=watchouts,
144
+ yesterday_done=yesterday,
145
+ open_count=len(open_recs),
146
+ stale_count=stale_count,
147
+ )
148
+
149
+
150
+ def _resolve_target_projects(
151
+ records: list[FileRecord],
152
+ *,
153
+ project: str | None,
154
+ all_projects: bool,
155
+ cwd_project: str | None,
156
+ ) -> list[str]:
157
+ if all_projects:
158
+ names = sorted({r.project for r in records if r.project})
159
+ return names
160
+ if project:
161
+ return [project]
162
+ if cwd_project:
163
+ return [cwd_project]
164
+ # cwd 解決ができない時は records から先頭プロジェクトを 1 つ
165
+ names = sorted({r.project for r in records if r.project})
166
+ return names[:1]
167
+
168
+
169
+ def _detect_cwd_project(config: Config) -> str | None:
170
+ """現在ディレクトリが含まれるプロジェクトを推測する(``cwd プロジェクト`` 既定)。
171
+
172
+ まず ``config.roots`` 配下に cwd が含まれていれば、その root の name を返す。
173
+ git remote が使える場合はそちら優先。失敗時は None(呼び出し側で他のフォールバック)。
174
+ """
175
+ import os
176
+ import subprocess
177
+
178
+ cwd = Path(os.getcwd()).resolve()
179
+ try:
180
+ result = subprocess.run(
181
+ ["git", "-C", str(cwd), "remote", "get-url", "origin"],
182
+ capture_output=True, text=True, timeout=2,
183
+ )
184
+ if result.returncode == 0:
185
+ url = result.stdout.strip()
186
+ if url:
187
+ tail = url.rstrip("/").split("/")[-1]
188
+ if tail.endswith(".git"):
189
+ tail = tail[:-4]
190
+ if tail:
191
+ return tail
192
+ except (OSError, subprocess.SubprocessError):
193
+ pass
194
+
195
+ for root in config.roots:
196
+ try:
197
+ cwd.relative_to(Path(root).resolve())
198
+ return Path(root).name
199
+ except ValueError:
200
+ continue
201
+ return None
202
+
203
+
204
+ def build_brief(
205
+ config: Config,
206
+ *,
207
+ project: str | None = None,
208
+ all_projects: bool = False,
209
+ today: date | None = None,
210
+ ) -> BriefResult:
211
+ """brief を 1 回ぶん組み立てて返す。
212
+
213
+ Args:
214
+ config: ロード済み Config
215
+ project: 単一プロジェクト指定(``project_id`` 文字列)
216
+ all_projects: True で search_paths の全プロジェクトを横並び要約
217
+ today: テスト用の日付固定。未指定なら ``date.today()``
218
+ """
219
+ now = datetime.now(timezone.utc).astimezone()
220
+ today_date = today or now.date()
221
+
222
+ records = scan_records(config)
223
+
224
+ cwd_proj = _detect_cwd_project(config) if not (project or all_projects) else None
225
+ targets = _resolve_target_projects(
226
+ records, project=project, all_projects=all_projects, cwd_project=cwd_proj,
227
+ )
228
+
229
+ by_project: dict[str, list[FileRecord]] = {}
230
+ for r in records:
231
+ if r.project:
232
+ by_project.setdefault(r.project, []).append(r)
233
+
234
+ projects: list[ProjectBrief] = []
235
+ for name in targets:
236
+ proj_recs = by_project.get(name, [])
237
+ projects.append(_build_for_project(proj_recs, name, today=today_date, now=now))
238
+
239
+ return BriefResult(
240
+ mode="all" if all_projects else "single",
241
+ generated_at=now.isoformat(),
242
+ projects=projects,
243
+ )
@@ -0,0 +1,15 @@
1
+ """C2 (wings): capture — 会話履歴から plan / bugfix / pending の草案を抽出する上流の双子。
2
+
3
+ 設計の要点:
4
+ - Heuristic 経路(LLM 無し)と LLM 経路(mock / 実 provider)の 2 系統
5
+ - 草案は :class:`models.Draft` で表現し、ユーザーが番号選択で採用 → frontmatter 付き md を生成
6
+ - 実 LLM API(OpenAI/Anthropic)は本 plan では呼ばない(abstract + Mock のみ)
7
+ """
8
+
9
+ from .models import Draft, DraftKind
10
+ from .service import (
11
+ extract_drafts,
12
+ save_drafts,
13
+ )
14
+
15
+ __all__ = ["Draft", "DraftKind", "extract_drafts", "save_drafts"]
@@ -0,0 +1,69 @@
1
+ """LLM を使わずに会話履歴から草案を拾うヒューリスティック。
2
+
3
+ LLM 経路の前段として、決定事項マーカー(「決定」「TODO」「バグ」等)を含む段落を
4
+ 切り出し、最低限の Draft を作る。LLM が無くても CLI / MCP の口は動く。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from .llm import _make_draft
12
+ from .models import Draft, DraftKind
13
+
14
+ # 決定事項マーカー(段落単位で拾う)。
15
+ PLAN_MARKERS = ("決定", "やる", "実装する", "TODO", "todo", "次に", "やろう", "対応する")
16
+ BUGFIX_MARKERS = ("バグ", "不具合", "壊れ", "再現", "エラー", "落ちる", "出ない")
17
+ PENDING_MARKERS = ("保留", "あとで", "ペンディング", "棚上げ")
18
+
19
+
20
+ def _split_paragraphs(text: str) -> list[str]:
21
+ """1 行空きで段落を切る。連続改行は 1 つの区切りに丸める。"""
22
+ chunks = re.split(r"\n\s*\n", text)
23
+ return [c.strip() for c in chunks if c.strip()]
24
+
25
+
26
+ def _classify_paragraph(para: str) -> str | None:
27
+ if any(m in para for m in BUGFIX_MARKERS):
28
+ return DraftKind.BUGFIX.value
29
+ if any(m in para for m in PENDING_MARKERS):
30
+ return DraftKind.PENDING.value
31
+ if any(m in para for m in PLAN_MARKERS):
32
+ return DraftKind.PLAN.value
33
+ return None
34
+
35
+
36
+ def _extract_title(para: str) -> str:
37
+ """段落の最初の意味行をタイトルにする。記号・改行は削る。"""
38
+ for line in para.splitlines():
39
+ s = line.strip()
40
+ if not s:
41
+ continue
42
+ # H1 / 箇条書き記号を剥がす
43
+ s = re.sub(r"^[#\-*>\s]+", "", s).strip()
44
+ if s:
45
+ return s[:60]
46
+ return para[:60]
47
+
48
+
49
+ def extract_drafts_heuristic(
50
+ text: str, *, project: str | None = None, max_drafts: int = 5
51
+ ) -> list[Draft]:
52
+ """LLM 不要のヒューリスティック抽出。"""
53
+ drafts: list[Draft] = []
54
+ for para in _split_paragraphs(text):
55
+ kind = _classify_paragraph(para)
56
+ if kind is None:
57
+ continue
58
+ title = _extract_title(para)
59
+ drafts.append(_make_draft(
60
+ idx=len(drafts) + 1,
61
+ kind=kind,
62
+ title=title,
63
+ body_seed=para,
64
+ source_hint="heuristic",
65
+ project=project,
66
+ ))
67
+ if len(drafts) >= max_drafts:
68
+ break
69
+ return drafts