lyingdocs 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.
- lyingdocs/__init__.py +3 -0
- lyingdocs/__main__.py +5 -0
- lyingdocs/agent.py +352 -0
- lyingdocs/cli.py +149 -0
- lyingdocs/codex.py +150 -0
- lyingdocs/config.py +129 -0
- lyingdocs/doctree.py +159 -0
- lyingdocs/llm.py +94 -0
- lyingdocs/prompts/agent_system.txt +55 -0
- lyingdocs/prompts/codex_task.txt +28 -0
- lyingdocs/prompts/report_synthesis.txt +54 -0
- lyingdocs/tools.py +423 -0
- lyingdocs/workspace.py +170 -0
- lyingdocs-0.1.0.dist-info/METADATA +174 -0
- lyingdocs-0.1.0.dist-info/RECORD +18 -0
- lyingdocs-0.1.0.dist-info/WHEEL +4 -0
- lyingdocs-0.1.0.dist-info/entry_points.txt +2 -0
- lyingdocs-0.1.0.dist-info/licenses/LICENSE +21 -0
lyingdocs/tools.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Tool implementations and OpenAI function schemas for DocentAgent."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .codex import run_codex_task
|
|
9
|
+
from .workspace import CATEGORIES, SEVERITIES, Workspace
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("lyingdocs")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Tool Schemas (OpenAI function-calling format)
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
TOOL_SCHEMAS = [
|
|
19
|
+
{
|
|
20
|
+
"type": "function",
|
|
21
|
+
"function": {
|
|
22
|
+
"name": "list_docs",
|
|
23
|
+
"description": (
|
|
24
|
+
"List documentation files and subdirectories under a given path. "
|
|
25
|
+
"Returns file names, sizes, and types. Use to explore the doc tree."
|
|
26
|
+
),
|
|
27
|
+
"parameters": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"directory": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Relative path within the doc root. Use '.' for the root.",
|
|
33
|
+
"default": ".",
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"required": [],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"type": "function",
|
|
42
|
+
"function": {
|
|
43
|
+
"name": "read_doc",
|
|
44
|
+
"description": (
|
|
45
|
+
"Read a documentation file's content. Supports optional line ranges "
|
|
46
|
+
"for large files. Returns content with line numbers."
|
|
47
|
+
),
|
|
48
|
+
"parameters": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"path": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Relative path to the doc file within doc root.",
|
|
54
|
+
},
|
|
55
|
+
"start_line": {
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"description": "Start reading from this line (1-indexed). Default: 1.",
|
|
58
|
+
"default": 1,
|
|
59
|
+
},
|
|
60
|
+
"end_line": {
|
|
61
|
+
"type": "integer",
|
|
62
|
+
"description": "Stop reading at this line (inclusive). Default: read all.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"required": ["path"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "function",
|
|
71
|
+
"function": {
|
|
72
|
+
"name": "search_docs",
|
|
73
|
+
"description": (
|
|
74
|
+
"Search across documentation files for a pattern (regex or substring). "
|
|
75
|
+
"Returns matching lines with file paths and line numbers. Max 50 results."
|
|
76
|
+
),
|
|
77
|
+
"parameters": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"pattern": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"description": "Search pattern (regex supported).",
|
|
83
|
+
},
|
|
84
|
+
"glob": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"description": "Glob pattern to filter files. Default: '*.md'.",
|
|
87
|
+
"default": "*.md",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
"required": ["pattern"],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"type": "function",
|
|
96
|
+
"function": {
|
|
97
|
+
"name": "dispatch_codex",
|
|
98
|
+
"description": (
|
|
99
|
+
"Dispatch an atomic code analysis task to Codex CLI. The task should be "
|
|
100
|
+
"specific and targeted — reference concrete doc claims and what to verify "
|
|
101
|
+
"in the codebase. Each dispatch uses one unit of your budget."
|
|
102
|
+
),
|
|
103
|
+
"parameters": {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {
|
|
106
|
+
"task_description": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": (
|
|
109
|
+
"Specific audit question. Must reference concrete doc claims "
|
|
110
|
+
"and what to verify in code. Example: 'The docs at "
|
|
111
|
+
"docs/api/auth.md:45-60 claim OAuth2 PKCE is used for "
|
|
112
|
+
"all public clients. Verify the auth middleware implements "
|
|
113
|
+
"PKCE validation.'"
|
|
114
|
+
),
|
|
115
|
+
},
|
|
116
|
+
"focus_paths": {
|
|
117
|
+
"type": "array",
|
|
118
|
+
"items": {"type": "string"},
|
|
119
|
+
"description": (
|
|
120
|
+
"Optional list of code paths to prioritize (relative to code root)."
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
"required": ["task_description"],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"type": "function",
|
|
130
|
+
"function": {
|
|
131
|
+
"name": "record_finding",
|
|
132
|
+
"description": (
|
|
133
|
+
"Record a documentation-code misalignment finding. Call this after "
|
|
134
|
+
"analyzing Codex output to save confirmed findings."
|
|
135
|
+
),
|
|
136
|
+
"parameters": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"properties": {
|
|
139
|
+
"category": {
|
|
140
|
+
"type": "string",
|
|
141
|
+
"enum": list(CATEGORIES),
|
|
142
|
+
"description": (
|
|
143
|
+
"LogicMismatch: code contradicts doc. "
|
|
144
|
+
"PhantomSpec: doc describes something absent from code. "
|
|
145
|
+
"ShadowLogic: important code logic not documented. "
|
|
146
|
+
"HardcodedDrift: important params hardcoded."
|
|
147
|
+
),
|
|
148
|
+
},
|
|
149
|
+
"title": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "Short descriptive title for the finding.",
|
|
152
|
+
},
|
|
153
|
+
"doc_ref": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"description": "Doc file path + line/section reference.",
|
|
156
|
+
},
|
|
157
|
+
"code_ref": {
|
|
158
|
+
"type": "string",
|
|
159
|
+
"description": "Code file path + line number (from Codex output).",
|
|
160
|
+
},
|
|
161
|
+
"description": {
|
|
162
|
+
"type": "string",
|
|
163
|
+
"description": "Detailed explanation of the misalignment.",
|
|
164
|
+
},
|
|
165
|
+
"severity": {
|
|
166
|
+
"type": "string",
|
|
167
|
+
"enum": list(SEVERITIES),
|
|
168
|
+
"description": "Impact severity: high, medium, or low.",
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
"required": [
|
|
172
|
+
"category", "title", "doc_ref", "code_ref",
|
|
173
|
+
"description", "severity",
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"type": "function",
|
|
180
|
+
"function": {
|
|
181
|
+
"name": "get_progress",
|
|
182
|
+
"description": (
|
|
183
|
+
"Check current audit progress: sections completed, findings by category, "
|
|
184
|
+
"Codex dispatch budget remaining."
|
|
185
|
+
),
|
|
186
|
+
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"type": "function",
|
|
191
|
+
"function": {
|
|
192
|
+
"name": "mark_section_complete",
|
|
193
|
+
"description": "Mark a documentation section/file as fully audited.",
|
|
194
|
+
"parameters": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"properties": {
|
|
197
|
+
"section_path": {
|
|
198
|
+
"type": "string",
|
|
199
|
+
"description": "Path to the doc section/file that has been audited.",
|
|
200
|
+
},
|
|
201
|
+
"notes": {
|
|
202
|
+
"type": "string",
|
|
203
|
+
"description": "Optional notes about what was found or not found.",
|
|
204
|
+
"default": "",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
"required": ["section_path"],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"type": "function",
|
|
213
|
+
"function": {
|
|
214
|
+
"name": "finalize_report",
|
|
215
|
+
"description": (
|
|
216
|
+
"Signal that all auditing is complete. Triggers final report generation. "
|
|
217
|
+
"Call this when you have audited all high-priority sections or the "
|
|
218
|
+
"dispatch budget is running low."
|
|
219
|
+
),
|
|
220
|
+
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Tool Executor
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ToolExecutor:
|
|
232
|
+
"""Executes tool calls from the agent and returns results."""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
doc_root: Path,
|
|
237
|
+
code_path: Path,
|
|
238
|
+
output_dir: Path,
|
|
239
|
+
workspace: Workspace,
|
|
240
|
+
config: dict,
|
|
241
|
+
codex_bin: str | None = None,
|
|
242
|
+
):
|
|
243
|
+
self.doc_root = doc_root.resolve()
|
|
244
|
+
self.code_path = code_path
|
|
245
|
+
self.output_dir = output_dir
|
|
246
|
+
self.workspace = workspace
|
|
247
|
+
self.config = config
|
|
248
|
+
self.codex_bin = codex_bin
|
|
249
|
+
|
|
250
|
+
def execute(self, tool_name: str, arguments: dict) -> str:
|
|
251
|
+
"""Dispatch a tool call and return the result string."""
|
|
252
|
+
handler = getattr(self, f"_tool_{tool_name}", None)
|
|
253
|
+
if handler is None:
|
|
254
|
+
return f"[ERROR] Unknown tool: {tool_name}"
|
|
255
|
+
try:
|
|
256
|
+
return handler(**arguments)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.error("Tool %s failed: %s", tool_name, e)
|
|
259
|
+
return f"[ERROR] {tool_name} failed: {e}"
|
|
260
|
+
|
|
261
|
+
# -- Tool implementations --
|
|
262
|
+
|
|
263
|
+
def _tool_list_docs(self, directory: str = ".") -> str:
|
|
264
|
+
target = (self.doc_root / directory).resolve()
|
|
265
|
+
if not str(target).startswith(str(self.doc_root)):
|
|
266
|
+
return "[ERROR] Path outside doc root."
|
|
267
|
+
if not target.is_dir():
|
|
268
|
+
return f"[ERROR] Not a directory: {directory}"
|
|
269
|
+
|
|
270
|
+
lines = [f"Contents of {directory}/:\n"]
|
|
271
|
+
|
|
272
|
+
# Directories first
|
|
273
|
+
dirs = sorted(
|
|
274
|
+
p for p in target.iterdir()
|
|
275
|
+
if p.is_dir() and not p.name.startswith(".")
|
|
276
|
+
)
|
|
277
|
+
for d in dirs:
|
|
278
|
+
file_count = sum(1 for _ in d.rglob("*") if _.is_file())
|
|
279
|
+
lines.append(f" 📁 {d.name}/ ({file_count} files)")
|
|
280
|
+
|
|
281
|
+
# Then files
|
|
282
|
+
files = sorted(p for p in target.iterdir() if p.is_file())
|
|
283
|
+
for f in files:
|
|
284
|
+
size = f.stat().st_size
|
|
285
|
+
lines.append(f" 📄 {f.name} ({_human_size(size)})")
|
|
286
|
+
|
|
287
|
+
return "\n".join(lines)
|
|
288
|
+
|
|
289
|
+
def _tool_read_doc(
|
|
290
|
+
self, path: str, start_line: int = 1, end_line: int | None = None
|
|
291
|
+
) -> str:
|
|
292
|
+
target = (self.doc_root / path).resolve()
|
|
293
|
+
if not str(target).startswith(str(self.doc_root)):
|
|
294
|
+
return "[ERROR] Path outside doc root."
|
|
295
|
+
if not target.is_file():
|
|
296
|
+
return f"[ERROR] File not found: {path}"
|
|
297
|
+
|
|
298
|
+
content = target.read_text(encoding="utf-8", errors="replace")
|
|
299
|
+
all_lines = content.splitlines()
|
|
300
|
+
|
|
301
|
+
start = max(0, start_line - 1)
|
|
302
|
+
end = end_line if end_line else len(all_lines)
|
|
303
|
+
selected = all_lines[start:end]
|
|
304
|
+
|
|
305
|
+
numbered = [
|
|
306
|
+
f"{i + start + 1:4d} | {line}" for i, line in enumerate(selected)
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
header = f"File: {path} (lines {start + 1}-{start + len(selected)}/{len(all_lines)})\n"
|
|
310
|
+
return header + "\n".join(numbered)
|
|
311
|
+
|
|
312
|
+
def _tool_search_docs(self, pattern: str, glob: str = "*.md") -> str:
|
|
313
|
+
results = []
|
|
314
|
+
try:
|
|
315
|
+
regex = re.compile(pattern, re.IGNORECASE)
|
|
316
|
+
except re.error:
|
|
317
|
+
# Fall back to literal search
|
|
318
|
+
regex = re.compile(re.escape(pattern), re.IGNORECASE)
|
|
319
|
+
|
|
320
|
+
for path in sorted(self.doc_root.rglob(glob)):
|
|
321
|
+
if not path.is_file():
|
|
322
|
+
continue
|
|
323
|
+
# Skip hidden/non-doc dirs
|
|
324
|
+
rel = path.relative_to(self.doc_root)
|
|
325
|
+
if any(p.startswith(".") for p in rel.parts):
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
330
|
+
except Exception:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
for i, line in enumerate(lines, 1):
|
|
334
|
+
if regex.search(line):
|
|
335
|
+
results.append(f"{rel}:{i}: {line.strip()}")
|
|
336
|
+
if len(results) >= 50:
|
|
337
|
+
results.append("... (truncated at 50 results)")
|
|
338
|
+
return "\n".join(results)
|
|
339
|
+
|
|
340
|
+
if not results:
|
|
341
|
+
return f"No matches found for pattern: {pattern}"
|
|
342
|
+
return "\n".join(results)
|
|
343
|
+
|
|
344
|
+
def _tool_dispatch_codex(
|
|
345
|
+
self, task_description: str, focus_paths: list[str] | None = None
|
|
346
|
+
) -> str:
|
|
347
|
+
if not self.config.get("codex_enabled", True) or not self.codex_bin:
|
|
348
|
+
return (
|
|
349
|
+
"[UNAVAILABLE] Codex CLI is not available. "
|
|
350
|
+
"Install via 'npm install -g @openai/codex' or enable it in config. "
|
|
351
|
+
"Continue auditing using documentation analysis only."
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if self.workspace.is_budget_exhausted():
|
|
355
|
+
return (
|
|
356
|
+
"[ERROR] Codex dispatch budget exhausted. "
|
|
357
|
+
"You should finalize the report with the findings collected so far."
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
self.workspace.increment_dispatch()
|
|
361
|
+
task_id = f"{self.workspace.codex_dispatch_count:03d}"
|
|
362
|
+
|
|
363
|
+
result = run_codex_task(
|
|
364
|
+
self.config,
|
|
365
|
+
task_description,
|
|
366
|
+
self.code_path,
|
|
367
|
+
self.output_dir,
|
|
368
|
+
task_id,
|
|
369
|
+
focus_paths,
|
|
370
|
+
codex_bin=self.codex_bin,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
remaining = self.workspace.dispatches_remaining()
|
|
374
|
+
footer = f"\n\n[Codex dispatches remaining: {remaining}/{self.workspace.max_dispatches}]"
|
|
375
|
+
return result + footer
|
|
376
|
+
|
|
377
|
+
def _tool_record_finding(
|
|
378
|
+
self,
|
|
379
|
+
category: str,
|
|
380
|
+
title: str,
|
|
381
|
+
doc_ref: str,
|
|
382
|
+
code_ref: str,
|
|
383
|
+
description: str,
|
|
384
|
+
severity: str,
|
|
385
|
+
) -> str:
|
|
386
|
+
finding = self.workspace.add_finding(
|
|
387
|
+
category=category,
|
|
388
|
+
title=title,
|
|
389
|
+
doc_ref=doc_ref,
|
|
390
|
+
code_ref=code_ref,
|
|
391
|
+
description=description,
|
|
392
|
+
severity=severity,
|
|
393
|
+
)
|
|
394
|
+
return (
|
|
395
|
+
f"Finding recorded: [{finding.category}] {finding.title} "
|
|
396
|
+
f"(id: {finding.id}, severity: {finding.severity})"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _tool_get_progress(self) -> str:
|
|
400
|
+
return self.workspace.get_progress_summary()
|
|
401
|
+
|
|
402
|
+
def _tool_mark_section_complete(
|
|
403
|
+
self, section_path: str, notes: str = ""
|
|
404
|
+
) -> str:
|
|
405
|
+
self.workspace.mark_section_complete(section_path, notes)
|
|
406
|
+
return f"Section marked complete: {section_path}"
|
|
407
|
+
|
|
408
|
+
def _tool_finalize_report(self) -> str:
|
|
409
|
+
self.workspace.finalize()
|
|
410
|
+
return (
|
|
411
|
+
"Report finalization triggered. "
|
|
412
|
+
f"Total findings: {len(self.workspace.findings)}. "
|
|
413
|
+
"The final report will be generated now."
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _human_size(size: int) -> str:
|
|
418
|
+
if size < 1024:
|
|
419
|
+
return f"{size}B"
|
|
420
|
+
elif size < 1024 * 1024:
|
|
421
|
+
return f"{size / 1024:.1f}KB"
|
|
422
|
+
else:
|
|
423
|
+
return f"{size / (1024 * 1024):.1f}MB"
|
lyingdocs/workspace.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Workspace state: findings, progress tracking, and persistence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("lyingdocs")
|
|
12
|
+
|
|
13
|
+
CATEGORIES = ("LogicMismatch", "PhantomSpec", "ShadowLogic", "HardcodedDrift")
|
|
14
|
+
SEVERITIES = ("high", "medium", "low")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Finding:
|
|
19
|
+
id: str
|
|
20
|
+
category: str
|
|
21
|
+
title: str
|
|
22
|
+
doc_ref: str
|
|
23
|
+
code_ref: str
|
|
24
|
+
description: str
|
|
25
|
+
severity: str
|
|
26
|
+
timestamp: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Workspace:
|
|
30
|
+
"""Manages audit state: findings, completed sections, and dispatch budget."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, output_dir: Path, max_dispatches: int = 20):
|
|
33
|
+
self.output_dir = output_dir
|
|
34
|
+
self.max_dispatches = max_dispatches
|
|
35
|
+
self.findings: list[Finding] = []
|
|
36
|
+
self.completed_sections: set[str] = set()
|
|
37
|
+
self.codex_dispatch_count: int = 0
|
|
38
|
+
self._finalized: bool = False
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
def add_finding(
|
|
44
|
+
self,
|
|
45
|
+
category: str,
|
|
46
|
+
title: str,
|
|
47
|
+
doc_ref: str,
|
|
48
|
+
code_ref: str,
|
|
49
|
+
description: str,
|
|
50
|
+
severity: str,
|
|
51
|
+
) -> Finding:
|
|
52
|
+
"""Record a new misalignment finding."""
|
|
53
|
+
if category not in CATEGORIES:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Invalid category '{category}'. Must be one of: {CATEGORIES}"
|
|
56
|
+
)
|
|
57
|
+
if severity not in SEVERITIES:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Invalid severity '{severity}'. Must be one of: {SEVERITIES}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
finding = Finding(
|
|
63
|
+
id=str(uuid.uuid4())[:8],
|
|
64
|
+
category=category,
|
|
65
|
+
title=title,
|
|
66
|
+
doc_ref=doc_ref,
|
|
67
|
+
code_ref=code_ref,
|
|
68
|
+
description=description,
|
|
69
|
+
severity=severity,
|
|
70
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
71
|
+
)
|
|
72
|
+
with self._lock:
|
|
73
|
+
self.findings.append(finding)
|
|
74
|
+
|
|
75
|
+
# Append to JSONL for crash recovery
|
|
76
|
+
with open(self.output_dir / "findings.jsonl", "a", encoding="utf-8") as f:
|
|
77
|
+
f.write(json.dumps(asdict(finding)) + "\n")
|
|
78
|
+
|
|
79
|
+
logger.info(
|
|
80
|
+
" Finding recorded: [%s] %s (%s)", category, title, severity
|
|
81
|
+
)
|
|
82
|
+
return finding
|
|
83
|
+
|
|
84
|
+
def mark_section_complete(self, section_path: str, notes: str = "") -> None:
|
|
85
|
+
with self._lock:
|
|
86
|
+
self.completed_sections.add(section_path)
|
|
87
|
+
logger.info(" Section completed: %s", section_path)
|
|
88
|
+
|
|
89
|
+
def increment_dispatch(self) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
self.codex_dispatch_count += 1
|
|
92
|
+
|
|
93
|
+
def dispatches_remaining(self) -> int:
|
|
94
|
+
with self._lock:
|
|
95
|
+
return max(0, self.max_dispatches - self.codex_dispatch_count)
|
|
96
|
+
|
|
97
|
+
def finalize(self) -> None:
|
|
98
|
+
with self._lock:
|
|
99
|
+
self._finalized = True
|
|
100
|
+
|
|
101
|
+
def is_complete(self) -> bool:
|
|
102
|
+
with self._lock:
|
|
103
|
+
return self._finalized
|
|
104
|
+
|
|
105
|
+
def is_budget_exhausted(self) -> bool:
|
|
106
|
+
with self._lock:
|
|
107
|
+
return self.codex_dispatch_count >= self.max_dispatches
|
|
108
|
+
|
|
109
|
+
def get_progress_summary(self) -> str:
|
|
110
|
+
"""Return a text summary of current audit progress."""
|
|
111
|
+
by_cat = {c: [] for c in CATEGORIES}
|
|
112
|
+
for f in self.findings:
|
|
113
|
+
by_cat[f.category].append(f)
|
|
114
|
+
|
|
115
|
+
lines = [
|
|
116
|
+
"## Audit Progress",
|
|
117
|
+
f"Codex dispatches: {self.codex_dispatch_count}/{self.max_dispatches}",
|
|
118
|
+
f"Sections completed: {len(self.completed_sections)}",
|
|
119
|
+
f"Total findings: {len(self.findings)}",
|
|
120
|
+
"",
|
|
121
|
+
"### Findings by Category",
|
|
122
|
+
]
|
|
123
|
+
for cat in CATEGORIES:
|
|
124
|
+
count = len(by_cat[cat])
|
|
125
|
+
if count:
|
|
126
|
+
lines.append(f" {cat}: {count}")
|
|
127
|
+
for f in by_cat[cat]:
|
|
128
|
+
lines.append(f" - [{f.severity}] {f.title}")
|
|
129
|
+
else:
|
|
130
|
+
lines.append(f" {cat}: 0")
|
|
131
|
+
|
|
132
|
+
if self.completed_sections:
|
|
133
|
+
lines.append("\n### Completed Sections")
|
|
134
|
+
for s in sorted(self.completed_sections):
|
|
135
|
+
lines.append(f" - {s}")
|
|
136
|
+
|
|
137
|
+
if self.is_budget_exhausted():
|
|
138
|
+
lines.append("\n⚠️ Codex dispatch budget exhausted.")
|
|
139
|
+
|
|
140
|
+
return "\n".join(lines)
|
|
141
|
+
|
|
142
|
+
def save_state(self) -> None:
|
|
143
|
+
"""Persist workspace state to JSON for resume capability."""
|
|
144
|
+
state = {
|
|
145
|
+
"findings": [asdict(f) for f in self.findings],
|
|
146
|
+
"completed_sections": sorted(self.completed_sections),
|
|
147
|
+
"codex_dispatch_count": self.codex_dispatch_count,
|
|
148
|
+
"finalized": self._finalized,
|
|
149
|
+
}
|
|
150
|
+
path = self.output_dir / "workspace_state.json"
|
|
151
|
+
path.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
def load_state(self) -> bool:
|
|
154
|
+
"""Load workspace state from checkpoint. Returns True if loaded."""
|
|
155
|
+
path = self.output_dir / "workspace_state.json"
|
|
156
|
+
if not path.exists():
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
160
|
+
self.findings = [Finding(**f) for f in state.get("findings", [])]
|
|
161
|
+
self.completed_sections = set(state.get("completed_sections", []))
|
|
162
|
+
self.codex_dispatch_count = state.get("codex_dispatch_count", 0)
|
|
163
|
+
self._finalized = state.get("finalized", False)
|
|
164
|
+
logger.info(
|
|
165
|
+
" Resumed workspace: %d findings, %d sections, %d dispatches",
|
|
166
|
+
len(self.findings),
|
|
167
|
+
len(self.completed_sections),
|
|
168
|
+
self.codex_dispatch_count,
|
|
169
|
+
)
|
|
170
|
+
return True
|