codex-coach 0.1.0 → 0.1.3
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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +8 -11
- package/assets/brand/codex-coach-banner.png +0 -0
- package/assets/brand/codex-coach-icon.png +0 -0
- package/assets/brand/codex-coach-logo.png +0 -0
- package/package.json +12 -1
- package/pyproject.toml +6 -1
- package/skills/codex-coach/SKILL.md +10 -2
- package/skills/codex-coach/references/config-suggestions.md +10 -0
- package/skills/codex-coach/references/privacy.md +3 -1
- package/src/codex_coach/cli.py +57 -1
- package/src/codex_coach/install.py +2 -1
- package/src/codex_coach/instruction_audit.py +547 -0
- package/src/codex_coach/paths.py +5 -0
- package/src/codex_coach/reports.py +125 -0
- package/assets/brand/codex-coach-icon.svg +0 -49
- package/assets/brand/codex-coach-logo.svg +0 -62
- package/assets/examples/how-it-works.png +0 -0
- package/assets/examples/how-it-works.svg +0 -71
- package/assets/examples/project-capsules.png +0 -0
- package/assets/examples/project-capsules.svg +0 -59
- package/assets/examples/prompt-lint.png +0 -0
- package/assets/examples/prompt-lint.svg +0 -54
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .parser import iter_log_paths
|
|
12
|
+
from .redaction import SECRET_RE, stable_hash
|
|
13
|
+
from .timeutil import parse_timestamp
|
|
14
|
+
|
|
15
|
+
GLOBAL_INSTRUCTION_FILES = (
|
|
16
|
+
"AGENTS.md",
|
|
17
|
+
"config.toml",
|
|
18
|
+
"instructions.md",
|
|
19
|
+
"custom-instructions.md",
|
|
20
|
+
"custom_instructions.md",
|
|
21
|
+
)
|
|
22
|
+
PROJECT_INSTRUCTION_FILES = ("AGENTS.md",)
|
|
23
|
+
|
|
24
|
+
VERIFY_MARKERS = (
|
|
25
|
+
"test",
|
|
26
|
+
"pytest",
|
|
27
|
+
"vitest",
|
|
28
|
+
"jest",
|
|
29
|
+
"playwright",
|
|
30
|
+
"lint",
|
|
31
|
+
"typecheck",
|
|
32
|
+
"tsc",
|
|
33
|
+
"build",
|
|
34
|
+
"verify",
|
|
35
|
+
)
|
|
36
|
+
RESUME_MARKERS = ("checkpoint", "resume", "compaction", "ledger", "checklist", "state file")
|
|
37
|
+
PROMPT_MARKERS = ("success state", "target", "goal", "context", "constraints")
|
|
38
|
+
GLOBAL_SCOPE_LEAK_MARKERS = (
|
|
39
|
+
"tailwind",
|
|
40
|
+
"supabase",
|
|
41
|
+
"next.js",
|
|
42
|
+
"expo",
|
|
43
|
+
"dark mode",
|
|
44
|
+
"neon",
|
|
45
|
+
"spacex",
|
|
46
|
+
"operator-grade",
|
|
47
|
+
"pytest",
|
|
48
|
+
"project-specific",
|
|
49
|
+
)
|
|
50
|
+
MODE_LOCK_MARKERS = (
|
|
51
|
+
"research only",
|
|
52
|
+
"analysis only",
|
|
53
|
+
"plan only",
|
|
54
|
+
"do not implement",
|
|
55
|
+
"do not edit",
|
|
56
|
+
"no code changes",
|
|
57
|
+
)
|
|
58
|
+
STALE_MARKERS = ("temporary", "for now", "legacy", "deprecated", "old workflow", "until we")
|
|
59
|
+
ABSOLUTE_RE = re.compile(r"\b(always|never|must|exact|only|do not|don't|avoid|follow this exact)\b", re.IGNORECASE)
|
|
60
|
+
NPM_TOKEN_RE = re.compile(r"\bnpm_[A-Za-z0-9]{20,}\b")
|
|
61
|
+
KEY_VALUE_SECRET_RE = re.compile(
|
|
62
|
+
r"\b(api[_-]?key|token|secret|password)\b\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{12,}",
|
|
63
|
+
re.IGNORECASE,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class RecentProject:
|
|
69
|
+
path: Path
|
|
70
|
+
label: str
|
|
71
|
+
events: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class InstructionCandidate:
|
|
76
|
+
path: Path
|
|
77
|
+
scope: str
|
|
78
|
+
source: str
|
|
79
|
+
project_labels: tuple[str, ...] = ()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def analyze_instructions(
|
|
83
|
+
home: Path,
|
|
84
|
+
codex_home: Path,
|
|
85
|
+
*,
|
|
86
|
+
since_dt: datetime | None = None,
|
|
87
|
+
since_label: str | None = None,
|
|
88
|
+
usage_facts: dict[str, Any] | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""Analyze local Codex instruction files without returning raw file content."""
|
|
91
|
+
|
|
92
|
+
recent_projects = collect_recent_projects(codex_home, since_dt=since_dt)
|
|
93
|
+
candidates = discover_instruction_files(home, codex_home, recent_projects)
|
|
94
|
+
usage_by_project = {
|
|
95
|
+
str(item.get("project")): item for item in (usage_facts or {}).get("project_capsules", []) if item.get("project")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
files: list[dict[str, Any]] = []
|
|
99
|
+
findings: list[dict[str, Any]] = []
|
|
100
|
+
suggestions: list[dict[str, Any]] = []
|
|
101
|
+
covered_projects: set[str] = set()
|
|
102
|
+
|
|
103
|
+
for candidate in candidates:
|
|
104
|
+
summary, file_findings, file_suggestions = _analyze_file(candidate, usage_by_project)
|
|
105
|
+
files.append(summary)
|
|
106
|
+
findings.extend(file_findings)
|
|
107
|
+
suggestions.extend(file_suggestions)
|
|
108
|
+
covered_projects.update(candidate.project_labels)
|
|
109
|
+
|
|
110
|
+
coverage_findings, coverage_suggestions = _coverage_findings(recent_projects, covered_projects)
|
|
111
|
+
findings.extend(coverage_findings)
|
|
112
|
+
suggestions.extend(coverage_suggestions)
|
|
113
|
+
|
|
114
|
+
findings = _dedupe(findings)
|
|
115
|
+
suggestions = _dedupe(suggestions)
|
|
116
|
+
high_findings = sum(1 for item in findings if item.get("severity") == "high")
|
|
117
|
+
status = "healthy"
|
|
118
|
+
if high_findings:
|
|
119
|
+
status = "needs_review"
|
|
120
|
+
elif findings:
|
|
121
|
+
status = "review"
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"schema_version": 1,
|
|
125
|
+
"since": since_label,
|
|
126
|
+
"status": status,
|
|
127
|
+
"files_reviewed": len(files),
|
|
128
|
+
"recent_projects": [
|
|
129
|
+
{
|
|
130
|
+
"project": project.label,
|
|
131
|
+
"events": project.events,
|
|
132
|
+
"has_playbook": project.label in covered_projects,
|
|
133
|
+
}
|
|
134
|
+
for project in recent_projects[:10]
|
|
135
|
+
],
|
|
136
|
+
"files": files,
|
|
137
|
+
"findings": findings,
|
|
138
|
+
"suggestions": suggestions,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def collect_recent_projects(codex_home: Path, *, since_dt: datetime | None = None) -> list[RecentProject]:
|
|
143
|
+
cwd_counts: Counter[str] = Counter()
|
|
144
|
+
for path in iter_log_paths(codex_home):
|
|
145
|
+
try:
|
|
146
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
147
|
+
except OSError:
|
|
148
|
+
continue
|
|
149
|
+
for line in lines:
|
|
150
|
+
if not line.strip():
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
event = json.loads(line)
|
|
154
|
+
except json.JSONDecodeError:
|
|
155
|
+
continue
|
|
156
|
+
timestamp = parse_timestamp(event.get("timestamp"))
|
|
157
|
+
if since_dt is not None and timestamp is not None and timestamp < since_dt:
|
|
158
|
+
continue
|
|
159
|
+
if event.get("type") not in {"session_meta", "turn_context"}:
|
|
160
|
+
continue
|
|
161
|
+
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
|
162
|
+
cwd = str(payload.get("cwd") or "").strip()
|
|
163
|
+
if cwd and cwd != "<unknown>":
|
|
164
|
+
cwd_counts[str(Path(cwd).expanduser())] += 1
|
|
165
|
+
|
|
166
|
+
projects = [
|
|
167
|
+
RecentProject(path=Path(cwd), label=_project_label(cwd), events=count)
|
|
168
|
+
for cwd, count in cwd_counts.most_common()
|
|
169
|
+
]
|
|
170
|
+
return projects
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def discover_instruction_files(
|
|
174
|
+
home: Path,
|
|
175
|
+
codex_home: Path,
|
|
176
|
+
recent_projects: list[RecentProject],
|
|
177
|
+
) -> list[InstructionCandidate]:
|
|
178
|
+
candidates: dict[Path, dict[str, Any]] = {}
|
|
179
|
+
|
|
180
|
+
def add(path: Path, *, scope: str, source: str, project_label: str | None = None) -> None:
|
|
181
|
+
if not path.is_file():
|
|
182
|
+
return
|
|
183
|
+
key = path.resolve()
|
|
184
|
+
current = candidates.setdefault(
|
|
185
|
+
key,
|
|
186
|
+
{
|
|
187
|
+
"path": key,
|
|
188
|
+
"scope": scope,
|
|
189
|
+
"source": source,
|
|
190
|
+
"project_labels": set(),
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
if scope == "global":
|
|
194
|
+
current["scope"] = "global"
|
|
195
|
+
if source not in str(current["source"]).split(","):
|
|
196
|
+
current["source"] = f"{current['source']},{source}"
|
|
197
|
+
if project_label:
|
|
198
|
+
current["project_labels"].add(project_label)
|
|
199
|
+
|
|
200
|
+
for filename in GLOBAL_INSTRUCTION_FILES:
|
|
201
|
+
add(codex_home / filename, scope="global", source="codex_home")
|
|
202
|
+
|
|
203
|
+
for project in recent_projects:
|
|
204
|
+
if not project.path.exists():
|
|
205
|
+
continue
|
|
206
|
+
for directory in _candidate_ancestors(project.path, home):
|
|
207
|
+
for filename in PROJECT_INSTRUCTION_FILES:
|
|
208
|
+
add(directory / filename, scope="project", source="project_ancestor", project_label=project.label)
|
|
209
|
+
|
|
210
|
+
results = [
|
|
211
|
+
InstructionCandidate(
|
|
212
|
+
path=item["path"],
|
|
213
|
+
scope=str(item["scope"]),
|
|
214
|
+
source=str(item["source"]),
|
|
215
|
+
project_labels=tuple(sorted(item["project_labels"])),
|
|
216
|
+
)
|
|
217
|
+
for item in candidates.values()
|
|
218
|
+
]
|
|
219
|
+
return sorted(results, key=lambda item: (item.scope != "global", item.path.name, stable_hash(str(item.path))))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _analyze_file(
|
|
223
|
+
candidate: InstructionCandidate,
|
|
224
|
+
usage_by_project: dict[str, dict[str, Any]],
|
|
225
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
226
|
+
try:
|
|
227
|
+
text = candidate.path.read_text(encoding="utf-8", errors="replace")
|
|
228
|
+
stat = candidate.path.stat()
|
|
229
|
+
except OSError:
|
|
230
|
+
return _summary(candidate, "", None), [], []
|
|
231
|
+
|
|
232
|
+
summary = _summary(candidate, text, stat.st_mtime)
|
|
233
|
+
lower = text.lower()
|
|
234
|
+
findings: list[dict[str, Any]] = []
|
|
235
|
+
suggestions: list[dict[str, Any]] = []
|
|
236
|
+
target = _target(candidate)
|
|
237
|
+
file_hash = summary["file_hash"]
|
|
238
|
+
|
|
239
|
+
if _contains_secret(text):
|
|
240
|
+
findings.append(
|
|
241
|
+
_finding(
|
|
242
|
+
f"secret-{file_hash}",
|
|
243
|
+
"security",
|
|
244
|
+
"high",
|
|
245
|
+
"Potential secret in instruction file",
|
|
246
|
+
"The file contains a token-shaped or key/value secret pattern. Move credentials to environment variables or a secret manager.",
|
|
247
|
+
candidate,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if any(marker in lower for marker in MODE_LOCK_MARKERS):
|
|
252
|
+
findings.append(
|
|
253
|
+
_finding(
|
|
254
|
+
f"mode-lock-{file_hash}",
|
|
255
|
+
"staleness",
|
|
256
|
+
"medium",
|
|
257
|
+
"Mode lock may be stale",
|
|
258
|
+
"The file appears to force a narrow mode such as research-only or no-code work. This is useful temporarily but risky as a standing rule.",
|
|
259
|
+
candidate,
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
suggestions.append(
|
|
263
|
+
_suggestion(
|
|
264
|
+
f"mode-lock-{file_hash}",
|
|
265
|
+
"Clarify temporary mode instructions",
|
|
266
|
+
"medium",
|
|
267
|
+
candidate.scope,
|
|
268
|
+
target,
|
|
269
|
+
"A narrow mode rule should say when it applies, otherwise Codex may avoid implementation when the user expects it.",
|
|
270
|
+
"Use narrow modes only when the user explicitly asks for them. Otherwise, implement, verify, and report the smallest useful change.",
|
|
271
|
+
"Remove this clarification if the workflow is intentionally research-only.",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if candidate.scope == "global" and any(marker in lower for marker in GLOBAL_SCOPE_LEAK_MARKERS):
|
|
276
|
+
findings.append(
|
|
277
|
+
_finding(
|
|
278
|
+
f"scope-leak-{file_hash}",
|
|
279
|
+
"scope",
|
|
280
|
+
"medium",
|
|
281
|
+
"Project-specific preference may be global",
|
|
282
|
+
"The global instruction file contains stack, style, or workflow markers that usually belong in a project AGENTS.md.",
|
|
283
|
+
candidate,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
suggestions.append(
|
|
287
|
+
_suggestion(
|
|
288
|
+
f"scope-leak-{file_hash}",
|
|
289
|
+
"Move project-specific rules out of global instructions",
|
|
290
|
+
"medium",
|
|
291
|
+
candidate.scope,
|
|
292
|
+
target,
|
|
293
|
+
"Global instructions should hold durable personal preferences. Project stack, UI style, and verification commands are safer in that project's AGENTS.md.",
|
|
294
|
+
"Keep global instructions about your communication and safety preferences. Put project stack, UI style, commands, and workflow rules in the relevant AGENTS.md.",
|
|
295
|
+
"Move the rule back only if it truly applies to every project.",
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if len(text) > 9000 or _word_count(text) > 1300:
|
|
300
|
+
findings.append(
|
|
301
|
+
_finding(
|
|
302
|
+
f"bloat-{file_hash}",
|
|
303
|
+
"maintainability",
|
|
304
|
+
"low",
|
|
305
|
+
"Instruction file is long",
|
|
306
|
+
"Long instruction files increase the chance of stale, duplicated, or over-specific rules.",
|
|
307
|
+
candidate,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if len(ABSOLUTE_RE.findall(text)) >= 14:
|
|
312
|
+
findings.append(
|
|
313
|
+
_finding(
|
|
314
|
+
f"absolute-rules-{file_hash}",
|
|
315
|
+
"maintainability",
|
|
316
|
+
"low",
|
|
317
|
+
"Many absolute rules",
|
|
318
|
+
"The file uses many absolute directives. Strong rules are useful, but too many can make Codex brittle across mixed tasks.",
|
|
319
|
+
candidate,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if any(marker in lower for marker in STALE_MARKERS):
|
|
324
|
+
findings.append(
|
|
325
|
+
_finding(
|
|
326
|
+
f"stale-language-{file_hash}",
|
|
327
|
+
"staleness",
|
|
328
|
+
"low",
|
|
329
|
+
"Stale-language markers found",
|
|
330
|
+
"The file contains temporary or legacy wording. Review whether those rules still match the current workflow.",
|
|
331
|
+
candidate,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
for project_label in candidate.project_labels:
|
|
336
|
+
usage = usage_by_project.get(project_label, {})
|
|
337
|
+
if not usage:
|
|
338
|
+
continue
|
|
339
|
+
suggestions.extend(_usage_suggestions(candidate, project_label, usage, lower, target, file_hash))
|
|
340
|
+
|
|
341
|
+
return summary, findings, suggestions
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _usage_suggestions(
|
|
345
|
+
candidate: InstructionCandidate,
|
|
346
|
+
project_label: str,
|
|
347
|
+
usage: dict[str, Any],
|
|
348
|
+
lower_text: str,
|
|
349
|
+
target: str,
|
|
350
|
+
file_hash: str,
|
|
351
|
+
) -> list[dict[str, Any]]:
|
|
352
|
+
suggestions: list[dict[str, Any]] = []
|
|
353
|
+
tool_calls = int(usage.get("tool_calls", 0) or 0)
|
|
354
|
+
verification_calls = int(usage.get("verification_tool_calls", 0) or 0)
|
|
355
|
+
compactions = int(usage.get("compactions", 0) or 0)
|
|
356
|
+
prompt_average = float(usage.get("prompt_quality_average", 0) or 0)
|
|
357
|
+
|
|
358
|
+
if tool_calls >= 6 and verification_calls / max(1, tool_calls) < 0.15 and not _has_any(lower_text, VERIFY_MARKERS):
|
|
359
|
+
suggestions.append(
|
|
360
|
+
_suggestion(
|
|
361
|
+
f"verify-{file_hash}-{stable_hash(project_label)}",
|
|
362
|
+
"Add a verification rule",
|
|
363
|
+
"high" if tool_calls >= 12 else "medium",
|
|
364
|
+
candidate.scope,
|
|
365
|
+
target,
|
|
366
|
+
f"{project_label} has implementation-like tool usage but this playbook does not mention verification.",
|
|
367
|
+
"Before final status, run the smallest meaningful project check: test, build, lint, typecheck, browser probe, or runtime health check.",
|
|
368
|
+
"Remove this rule if the project does not have a reliable local verification path.",
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if compactions > 0 and not _has_any(lower_text, RESUME_MARKERS):
|
|
373
|
+
suggestions.append(
|
|
374
|
+
_suggestion(
|
|
375
|
+
f"resume-{file_hash}-{stable_hash(project_label)}",
|
|
376
|
+
"Add a resume/checkpoint rule",
|
|
377
|
+
"medium",
|
|
378
|
+
candidate.scope,
|
|
379
|
+
target,
|
|
380
|
+
f"{project_label} had context compaction, but this playbook does not mention resume discipline.",
|
|
381
|
+
"For long tasks, keep a short durable checklist and verify files or generated artifacts before resuming after interruption or compaction.",
|
|
382
|
+
"Remove this rule if the project work is always short-lived.",
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if 0 < prompt_average < 5 and not _has_any(lower_text, PROMPT_MARKERS):
|
|
387
|
+
suggestions.append(
|
|
388
|
+
_suggestion(
|
|
389
|
+
f"prompt-shape-{file_hash}-{stable_hash(project_label)}",
|
|
390
|
+
"Add a prompt-shaping cue",
|
|
391
|
+
"medium",
|
|
392
|
+
candidate.scope,
|
|
393
|
+
target,
|
|
394
|
+
f"{project_label} has low prompt clarity scores and this playbook does not mention target or success-state cues.",
|
|
395
|
+
"When asking for work in this project, include the action, target subsystem or file, relevant constraint, and success state.",
|
|
396
|
+
"Remove this cue if it feels redundant after a week.",
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return suggestions
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _coverage_findings(
|
|
404
|
+
recent_projects: list[RecentProject],
|
|
405
|
+
covered_projects: set[str],
|
|
406
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
407
|
+
findings: list[dict[str, Any]] = []
|
|
408
|
+
suggestions: list[dict[str, Any]] = []
|
|
409
|
+
for project in recent_projects[:6]:
|
|
410
|
+
if project.label in covered_projects or project.events < 2 or not project.path.exists():
|
|
411
|
+
continue
|
|
412
|
+
project_hash = stable_hash(str(project.path))
|
|
413
|
+
findings.append(
|
|
414
|
+
{
|
|
415
|
+
"id": f"missing-playbook-{project_hash}",
|
|
416
|
+
"category": "coverage",
|
|
417
|
+
"severity": "medium",
|
|
418
|
+
"title": "Active project has no discovered AGENTS.md",
|
|
419
|
+
"body": "A recently used project does not appear to have an AGENTS.md in the working directory or its nearby ancestors.",
|
|
420
|
+
"scope": "project",
|
|
421
|
+
"target": project.label,
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
suggestions.append(
|
|
425
|
+
_suggestion(
|
|
426
|
+
f"missing-playbook-{project_hash}",
|
|
427
|
+
"Add a small project playbook",
|
|
428
|
+
"medium",
|
|
429
|
+
"project",
|
|
430
|
+
project.label,
|
|
431
|
+
"Codex Coach saw repeated activity in this project but no local AGENTS.md. A short playbook can prevent repeated context rebuilding.",
|
|
432
|
+
"# AGENTS\n\n## Project Context\n- Stack: [fill in]\n- Key commands: [test/build/lint]\n- Verification: run the smallest meaningful check before final status.\n- Constraints: [generated files, deployment, privacy, or style rules]\n",
|
|
433
|
+
"Delete the AGENTS.md if it does not improve project handoffs.",
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
return findings, suggestions
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _summary(candidate: InstructionCandidate, text: str, mtime: float | None) -> dict[str, Any]:
|
|
440
|
+
modified_at = None
|
|
441
|
+
if mtime is not None:
|
|
442
|
+
modified_at = datetime.fromtimestamp(mtime, tz=UTC).isoformat(timespec="seconds")
|
|
443
|
+
return {
|
|
444
|
+
"file": candidate.path.name,
|
|
445
|
+
"file_hash": stable_hash(str(candidate.path)),
|
|
446
|
+
"content_hash": stable_hash(text) if text else None,
|
|
447
|
+
"scope": candidate.scope,
|
|
448
|
+
"source": candidate.source,
|
|
449
|
+
"project_labels": list(candidate.project_labels),
|
|
450
|
+
"bytes": len(text.encode("utf-8", "ignore")),
|
|
451
|
+
"lines": len(text.splitlines()),
|
|
452
|
+
"modified_at": modified_at,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _candidate_ancestors(path: Path, home: Path) -> list[Path]:
|
|
457
|
+
start = path if path.is_dir() else path.parent
|
|
458
|
+
try:
|
|
459
|
+
current = start.resolve()
|
|
460
|
+
except OSError:
|
|
461
|
+
current = start
|
|
462
|
+
ancestors: list[Path] = []
|
|
463
|
+
for _ in range(8):
|
|
464
|
+
ancestors.append(current)
|
|
465
|
+
if current == current.parent:
|
|
466
|
+
break
|
|
467
|
+
if current == home:
|
|
468
|
+
break
|
|
469
|
+
current = current.parent
|
|
470
|
+
return ancestors
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _project_label(cwd: str) -> str:
|
|
474
|
+
path = Path(cwd)
|
|
475
|
+
name = path.name or "project"
|
|
476
|
+
return f"{name} [{stable_hash(cwd)}]"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _finding(
|
|
480
|
+
finding_id: str,
|
|
481
|
+
category: str,
|
|
482
|
+
severity: str,
|
|
483
|
+
title: str,
|
|
484
|
+
body: str,
|
|
485
|
+
candidate: InstructionCandidate,
|
|
486
|
+
) -> dict[str, Any]:
|
|
487
|
+
return {
|
|
488
|
+
"id": finding_id,
|
|
489
|
+
"category": category,
|
|
490
|
+
"severity": severity,
|
|
491
|
+
"title": title,
|
|
492
|
+
"body": body,
|
|
493
|
+
"scope": candidate.scope,
|
|
494
|
+
"target": _target(candidate),
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _suggestion(
|
|
499
|
+
suggestion_id: str,
|
|
500
|
+
title: str,
|
|
501
|
+
confidence: str,
|
|
502
|
+
scope: str,
|
|
503
|
+
target: str,
|
|
504
|
+
body: str,
|
|
505
|
+
suggested_text: str,
|
|
506
|
+
rollback: str,
|
|
507
|
+
) -> dict[str, Any]:
|
|
508
|
+
return {
|
|
509
|
+
"id": suggestion_id,
|
|
510
|
+
"title": title,
|
|
511
|
+
"confidence": confidence,
|
|
512
|
+
"scope": scope,
|
|
513
|
+
"target": target,
|
|
514
|
+
"body": body,
|
|
515
|
+
"suggested_text": suggested_text,
|
|
516
|
+
"rollback": rollback,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _target(candidate: InstructionCandidate) -> str:
|
|
521
|
+
if candidate.project_labels:
|
|
522
|
+
return ", ".join(candidate.project_labels[:3])
|
|
523
|
+
return f"{candidate.path.name} [{stable_hash(str(candidate.path))}]"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _contains_secret(text: str) -> bool:
|
|
527
|
+
return bool(SECRET_RE.search(text) or NPM_TOKEN_RE.search(text) or KEY_VALUE_SECRET_RE.search(text))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _word_count(text: str) -> int:
|
|
531
|
+
return len([word for word in re.split(r"\s+", text.strip()) if word])
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _has_any(text: str, markers: tuple[str, ...]) -> bool:
|
|
535
|
+
return any(marker in text for marker in markers)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _dedupe(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
539
|
+
seen: set[str] = set()
|
|
540
|
+
result: list[dict[str, Any]] = []
|
|
541
|
+
for item in items:
|
|
542
|
+
item_id = str(item.get("id") or stable_hash(json.dumps(item, sort_keys=True, default=str)))
|
|
543
|
+
if item_id in seen:
|
|
544
|
+
continue
|
|
545
|
+
seen.add(item_id)
|
|
546
|
+
result.append(item)
|
|
547
|
+
return result
|
package/src/codex_coach/paths.py
CHANGED
|
@@ -23,6 +23,10 @@ class CoachPaths:
|
|
|
23
23
|
def suggestions_dir(self) -> Path:
|
|
24
24
|
return self.coach_home / "suggestions"
|
|
25
25
|
|
|
26
|
+
@property
|
|
27
|
+
def instructions_dir(self) -> Path:
|
|
28
|
+
return self.coach_home / "instructions"
|
|
29
|
+
|
|
26
30
|
@property
|
|
27
31
|
def config_file(self) -> Path:
|
|
28
32
|
return self.coach_home / "config.toml"
|
|
@@ -35,6 +39,7 @@ class CoachPaths:
|
|
|
35
39
|
self.reports_dir.mkdir(parents=True, exist_ok=True)
|
|
36
40
|
self.facts_dir.mkdir(parents=True, exist_ok=True)
|
|
37
41
|
self.suggestions_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
self.instructions_dir.mkdir(parents=True, exist_ok=True)
|
|
38
43
|
|
|
39
44
|
|
|
40
45
|
def default_paths(
|