project-loop-harness 0.1.2__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.
- pcl/__init__.py +1 -0
- pcl/__main__.py +4 -0
- pcl/agents.py +501 -0
- pcl/checkpoints.py +201 -0
- pcl/cli.py +1404 -0
- pcl/commands.py +1006 -0
- pcl/db/migrations/001_initial.sql +180 -0
- pcl/db/schema.sql +180 -0
- pcl/db.py +49 -0
- pcl/decisions.py +275 -0
- pcl/errors.py +83 -0
- pcl/escalations.py +302 -0
- pcl/events.py +41 -0
- pcl/evidence.py +25 -0
- pcl/exporters.py +77 -0
- pcl/guards.py +14 -0
- pcl/ids.py +15 -0
- pcl/init_project.py +112 -0
- pcl/lifecycle.py +1073 -0
- pcl/links.py +108 -0
- pcl/mcp_server.py +328 -0
- pcl/migrations.py +220 -0
- pcl/paths.py +65 -0
- pcl/renderer.py +823 -0
- pcl/reports.py +766 -0
- pcl/resources.py +26 -0
- pcl/stories.py +762 -0
- pcl/templates/dashboard/dashboard.html +165 -0
- pcl/templates/project/AGENTS.block.md +16 -0
- pcl/templates/project/CLAUDE.block.md +13 -0
- pcl/templates/project/gitignore.fragment +13 -0
- pcl/templates/project/pcl.yaml +60 -0
- pcl/templates/skills/project-control-loop/SKILL.md +120 -0
- pcl/templates/workflows/defect_repair.yaml +61 -0
- pcl/templates/workflows/executor_smoke.yaml +32 -0
- pcl/templates/workflows/feature_coverage.yaml +52 -0
- pcl/templates/workflows/regression_loop.yaml +51 -0
- pcl/timeutil.py +7 -0
- pcl/validators.py +788 -0
- pcl/workflow_executor.py +911 -0
- pcl/workflow_proposal_validation.py +50 -0
- pcl/workflow_proposals.py +442 -0
- pcl/workflow_sandbox.py +683 -0
- pcl/workflow_verifier.py +333 -0
- pcl/workflow_yaml.py +190 -0
- pcl/workflows.py +569 -0
- project_loop_harness-0.1.2.dist-info/METADATA +361 -0
- project_loop_harness-0.1.2.dist-info/RECORD +52 -0
- project_loop_harness-0.1.2.dist-info/WHEEL +5 -0
- project_loop_harness-0.1.2.dist-info/entry_points.txt +3 -0
- project_loop_harness-0.1.2.dist-info/licenses/LICENSE +21 -0
- project_loop_harness-0.1.2.dist-info/top_level.txt +1 -0
pcl/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
pcl/__main__.py
ADDED
pcl/agents.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from shlex import quote
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .db import connect
|
|
9
|
+
from .errors import InvalidInputError
|
|
10
|
+
from .events import append_event
|
|
11
|
+
from .guards import require_initialized
|
|
12
|
+
from .ids import next_prefixed_id
|
|
13
|
+
from .paths import ProjectPaths
|
|
14
|
+
from .timeutil import utc_now_iso
|
|
15
|
+
from .workflows import read_job
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AgentCommand:
|
|
20
|
+
contract_version: str
|
|
21
|
+
adapter: str
|
|
22
|
+
job_id: str
|
|
23
|
+
prompt_path: str
|
|
24
|
+
output_path: str
|
|
25
|
+
ingest_command: str
|
|
26
|
+
expected_output_format: str
|
|
27
|
+
instructions: str
|
|
28
|
+
command: str | None = None
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"contract_version": self.contract_version,
|
|
33
|
+
"adapter": self.adapter,
|
|
34
|
+
"job_id": self.job_id,
|
|
35
|
+
"prompt_path": self.prompt_path,
|
|
36
|
+
"output_path": self.output_path,
|
|
37
|
+
"ingest_command": self.ingest_command,
|
|
38
|
+
"expected_output_format": self.expected_output_format,
|
|
39
|
+
"instructions": self.instructions,
|
|
40
|
+
"command": self.command,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AgentAdapter:
|
|
45
|
+
name = "manual"
|
|
46
|
+
|
|
47
|
+
def generate_command(self, paths: ProjectPaths, job: dict[str, Any]) -> AgentCommand:
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ManualAdapter(AgentAdapter):
|
|
52
|
+
name = "manual"
|
|
53
|
+
|
|
54
|
+
def generate_command(self, paths: ProjectPaths, job: dict[str, Any]) -> AgentCommand:
|
|
55
|
+
prompt_path = str(job["prompt_path"])
|
|
56
|
+
output_path = _default_output_path(job)
|
|
57
|
+
ingest_command = _ingest_command(output_path)
|
|
58
|
+
instructions = (
|
|
59
|
+
f"Read {prompt_path}, run the requested agent work manually, write the result to "
|
|
60
|
+
f"{output_path}, then run `{ingest_command}`."
|
|
61
|
+
)
|
|
62
|
+
return AgentCommand(
|
|
63
|
+
contract_version=CONTRACT_VERSION,
|
|
64
|
+
adapter=self.name,
|
|
65
|
+
job_id=str(job["id"]),
|
|
66
|
+
prompt_path=prompt_path,
|
|
67
|
+
output_path=output_path,
|
|
68
|
+
ingest_command=ingest_command,
|
|
69
|
+
expected_output_format=EXPECTED_OUTPUT_FORMAT,
|
|
70
|
+
instructions=instructions,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CodexExecAdapter(AgentAdapter):
|
|
75
|
+
name = "codex_exec"
|
|
76
|
+
|
|
77
|
+
def generate_command(self, paths: ProjectPaths, job: dict[str, Any]) -> AgentCommand:
|
|
78
|
+
prompt_path = str(job["prompt_path"])
|
|
79
|
+
output_path = _default_output_path(job)
|
|
80
|
+
ingest_command = _ingest_command(output_path, root=paths.root)
|
|
81
|
+
command = _codex_exec_command(
|
|
82
|
+
paths=paths,
|
|
83
|
+
prompt_path=prompt_path,
|
|
84
|
+
output_path=output_path,
|
|
85
|
+
ingest_command=ingest_command,
|
|
86
|
+
)
|
|
87
|
+
return AgentCommand(
|
|
88
|
+
contract_version=CONTRACT_VERSION,
|
|
89
|
+
adapter=self.name,
|
|
90
|
+
job_id=str(job["id"]),
|
|
91
|
+
prompt_path=prompt_path,
|
|
92
|
+
output_path=output_path,
|
|
93
|
+
ingest_command=ingest_command,
|
|
94
|
+
expected_output_format=EXPECTED_OUTPUT_FORMAT,
|
|
95
|
+
instructions=(
|
|
96
|
+
"Run this command locally only when you intentionally want Codex CLI to execute "
|
|
97
|
+
"the queued prompt. The command reads the prompt from stdin, writes the final "
|
|
98
|
+
"message to the expected output path, then ingests it. pcl does not call it "
|
|
99
|
+
"automatically and does not manage Codex CLI credentials."
|
|
100
|
+
),
|
|
101
|
+
command=command,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ClaudeManualAdapter(AgentAdapter):
|
|
106
|
+
name = "claude_manual"
|
|
107
|
+
|
|
108
|
+
def generate_command(self, paths: ProjectPaths, job: dict[str, Any]) -> AgentCommand:
|
|
109
|
+
prompt_path = str(job["prompt_path"])
|
|
110
|
+
output_path = _default_output_path(job)
|
|
111
|
+
ingest_command = _ingest_command(output_path)
|
|
112
|
+
instructions = _claude_manual_instructions(
|
|
113
|
+
prompt_path=prompt_path,
|
|
114
|
+
output_path=output_path,
|
|
115
|
+
ingest_command=ingest_command,
|
|
116
|
+
)
|
|
117
|
+
return AgentCommand(
|
|
118
|
+
contract_version=CONTRACT_VERSION,
|
|
119
|
+
adapter=self.name,
|
|
120
|
+
job_id=str(job["id"]),
|
|
121
|
+
prompt_path=prompt_path,
|
|
122
|
+
output_path=output_path,
|
|
123
|
+
ingest_command=ingest_command,
|
|
124
|
+
expected_output_format=EXPECTED_OUTPUT_FORMAT,
|
|
125
|
+
instructions=instructions,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class GenericShellAdapter(AgentAdapter):
|
|
130
|
+
name = "generic_shell"
|
|
131
|
+
|
|
132
|
+
def generate_command(self, paths: ProjectPaths, job: dict[str, Any]) -> AgentCommand:
|
|
133
|
+
prompt_path = str(job["prompt_path"])
|
|
134
|
+
output_path = _default_output_path(job)
|
|
135
|
+
ingest_command = _ingest_command(output_path, root=paths.root)
|
|
136
|
+
command = _generic_shell_command(
|
|
137
|
+
paths=paths,
|
|
138
|
+
prompt_path=prompt_path,
|
|
139
|
+
output_path=output_path,
|
|
140
|
+
ingest_command=ingest_command,
|
|
141
|
+
)
|
|
142
|
+
instructions = _generic_shell_instructions(
|
|
143
|
+
prompt_path=prompt_path,
|
|
144
|
+
output_path=output_path,
|
|
145
|
+
ingest_command=ingest_command,
|
|
146
|
+
)
|
|
147
|
+
return AgentCommand(
|
|
148
|
+
contract_version=CONTRACT_VERSION,
|
|
149
|
+
adapter=self.name,
|
|
150
|
+
job_id=str(job["id"]),
|
|
151
|
+
prompt_path=prompt_path,
|
|
152
|
+
output_path=output_path,
|
|
153
|
+
ingest_command=ingest_command,
|
|
154
|
+
expected_output_format=EXPECTED_OUTPUT_FORMAT,
|
|
155
|
+
instructions=instructions,
|
|
156
|
+
command=command,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
CONTRACT_VERSION = "agent-adapter-command/v1"
|
|
161
|
+
OUTPUT_CONTRACT_VERSION = "agent-output/v1"
|
|
162
|
+
REQUIRED_OUTPUT_HEADINGS = ("## Findings", "## Evidence")
|
|
163
|
+
EXPECTED_OUTPUT_FORMAT = (
|
|
164
|
+
"Markdown report matching agent-output/v1. First non-empty line must be an H1 summary; "
|
|
165
|
+
"include required headings: ## Findings and ## Evidence. Recommended pcl commands are optional."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
ADAPTERS: dict[str, AgentAdapter] = {
|
|
169
|
+
"manual": ManualAdapter(),
|
|
170
|
+
"codex_exec": CodexExecAdapter(),
|
|
171
|
+
"claude_manual": ClaudeManualAdapter(),
|
|
172
|
+
"generic_shell": GenericShellAdapter(),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def generate_agent_command(paths: ProjectPaths, job_id: str, adapter_name: str) -> AgentCommand:
|
|
177
|
+
require_initialized(paths)
|
|
178
|
+
job = read_job(paths, job_id)
|
|
179
|
+
try:
|
|
180
|
+
adapter = ADAPTERS[adapter_name]
|
|
181
|
+
except KeyError as exc:
|
|
182
|
+
raise InvalidInputError(
|
|
183
|
+
f"Unknown agent adapter: {adapter_name}",
|
|
184
|
+
details={"adapter": adapter_name, "available": sorted(ADAPTERS)},
|
|
185
|
+
) from exc
|
|
186
|
+
return adapter.generate_command(paths, job)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def read_job_prompt(paths: ProjectPaths, job_id: str) -> str:
|
|
190
|
+
job = read_job(paths, job_id)
|
|
191
|
+
return str(job.get("prompt") or "")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def read_job_prompt_handoff(paths: ProjectPaths, job_id: str) -> dict[str, Any]:
|
|
195
|
+
job = read_job(paths, job_id)
|
|
196
|
+
output_path = _default_output_path(job)
|
|
197
|
+
return {
|
|
198
|
+
"ok": True,
|
|
199
|
+
"job_id": str(job["id"]),
|
|
200
|
+
"workflow_run_id": str(job["workflow_run_id"]),
|
|
201
|
+
"workflow_id": str(job["workflow_id"]),
|
|
202
|
+
"role": str(job["role"]),
|
|
203
|
+
"status": str(job["status"]),
|
|
204
|
+
"prompt_path": str(job["prompt_path"]),
|
|
205
|
+
"output_path": output_path,
|
|
206
|
+
"ingest_command": _ingest_command(output_path),
|
|
207
|
+
"expected_output_format": EXPECTED_OUTPUT_FORMAT,
|
|
208
|
+
"prompt": str(job.get("prompt") or ""),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ingest_agent_run(paths: ProjectPaths, output_path: str | Path) -> dict[str, Any]:
|
|
213
|
+
require_initialized(paths)
|
|
214
|
+
path = _resolve_output_path(paths, output_path)
|
|
215
|
+
if not path.exists() or not path.is_file():
|
|
216
|
+
raise InvalidInputError(
|
|
217
|
+
f"Agent output file does not exist: {output_path}",
|
|
218
|
+
details={"path": str(output_path)},
|
|
219
|
+
)
|
|
220
|
+
job_id = _infer_job_id(paths, path)
|
|
221
|
+
relative_path = _relative_or_absolute(paths, path)
|
|
222
|
+
|
|
223
|
+
conn = connect(paths.db_path)
|
|
224
|
+
try:
|
|
225
|
+
row = conn.execute(
|
|
226
|
+
"""
|
|
227
|
+
SELECT
|
|
228
|
+
agent_jobs.id,
|
|
229
|
+
agent_jobs.workflow_run_id,
|
|
230
|
+
agent_jobs.status AS job_status,
|
|
231
|
+
workflow_runs.status AS run_status
|
|
232
|
+
FROM agent_jobs
|
|
233
|
+
JOIN workflow_runs ON workflow_runs.id = agent_jobs.workflow_run_id
|
|
234
|
+
WHERE agent_jobs.id = ?
|
|
235
|
+
""",
|
|
236
|
+
(job_id,),
|
|
237
|
+
).fetchone()
|
|
238
|
+
if row is None:
|
|
239
|
+
raise InvalidInputError(f"Agent job does not exist: {job_id}", details={"job_id": job_id})
|
|
240
|
+
_require_ingest_allowed(
|
|
241
|
+
job_id=job_id,
|
|
242
|
+
job_status=str(row["job_status"]),
|
|
243
|
+
workflow_run_id=str(row["workflow_run_id"]),
|
|
244
|
+
run_status=str(row["run_status"]),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
validation = _validate_output_contract(path=path, display_path=relative_path)
|
|
248
|
+
summary = str(validation["summary"])
|
|
249
|
+
now = utc_now_iso()
|
|
250
|
+
|
|
251
|
+
evidence_id = next_prefixed_id(conn, "evidence", "E")
|
|
252
|
+
conn.execute(
|
|
253
|
+
"""
|
|
254
|
+
INSERT INTO evidence(id, type, path, command, summary, created_at)
|
|
255
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
256
|
+
""",
|
|
257
|
+
(evidence_id, "agent_output", relative_path, None, summary, now),
|
|
258
|
+
)
|
|
259
|
+
conn.execute(
|
|
260
|
+
"""
|
|
261
|
+
UPDATE agent_jobs
|
|
262
|
+
SET status = ?, output_path = ?, ended_at = ?, summary = ?
|
|
263
|
+
WHERE id = ?
|
|
264
|
+
""",
|
|
265
|
+
("passed", relative_path, now, summary, job_id),
|
|
266
|
+
)
|
|
267
|
+
conn.execute(
|
|
268
|
+
"""
|
|
269
|
+
UPDATE workflow_runs
|
|
270
|
+
SET status = CASE WHEN status = 'queued' THEN 'running' ELSE status END
|
|
271
|
+
WHERE id = ?
|
|
272
|
+
""",
|
|
273
|
+
(row["workflow_run_id"],),
|
|
274
|
+
)
|
|
275
|
+
append_event(
|
|
276
|
+
conn=conn,
|
|
277
|
+
events_path=paths.events_path,
|
|
278
|
+
event_type="agent_output_ingested",
|
|
279
|
+
entity_type="agent_job",
|
|
280
|
+
entity_id=job_id,
|
|
281
|
+
payload={
|
|
282
|
+
"contract_version": OUTPUT_CONTRACT_VERSION,
|
|
283
|
+
"workflow_run_id": row["workflow_run_id"],
|
|
284
|
+
"evidence_id": evidence_id,
|
|
285
|
+
"output_path": relative_path,
|
|
286
|
+
"summary": summary,
|
|
287
|
+
"validation": validation,
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
conn.commit()
|
|
291
|
+
return {
|
|
292
|
+
"ok": True,
|
|
293
|
+
"contract_version": OUTPUT_CONTRACT_VERSION,
|
|
294
|
+
"job_id": job_id,
|
|
295
|
+
"workflow_run_id": row["workflow_run_id"],
|
|
296
|
+
"evidence_id": evidence_id,
|
|
297
|
+
"output_path": relative_path,
|
|
298
|
+
"summary": summary,
|
|
299
|
+
"status": "passed",
|
|
300
|
+
"validation": validation,
|
|
301
|
+
}
|
|
302
|
+
finally:
|
|
303
|
+
conn.close()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _default_output_path(job: dict[str, Any]) -> str:
|
|
307
|
+
prompt_path = Path(str(job["prompt_path"]))
|
|
308
|
+
return str(prompt_path.parent / "output.md")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _ingest_command(output_path: str, *, root: Path | None = None) -> str:
|
|
312
|
+
command = f"pcl ingest-agent-run {quote(output_path)}"
|
|
313
|
+
if root is not None:
|
|
314
|
+
command += f" --root {quote(str(root))}"
|
|
315
|
+
return command
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _codex_exec_command(
|
|
319
|
+
*,
|
|
320
|
+
paths: ProjectPaths,
|
|
321
|
+
prompt_path: str,
|
|
322
|
+
output_path: str,
|
|
323
|
+
ingest_command: str,
|
|
324
|
+
) -> str:
|
|
325
|
+
absolute_prompt = paths.root / prompt_path
|
|
326
|
+
absolute_output = paths.root / output_path
|
|
327
|
+
script = "\n".join(
|
|
328
|
+
[
|
|
329
|
+
"set -euo pipefail",
|
|
330
|
+
f"mkdir -p {quote(str(absolute_output.parent))}",
|
|
331
|
+
(
|
|
332
|
+
"codex exec "
|
|
333
|
+
f"--cd {quote(str(paths.root))} "
|
|
334
|
+
f"--output-last-message {quote(str(absolute_output))} "
|
|
335
|
+
f"- < {quote(str(absolute_prompt))}"
|
|
336
|
+
),
|
|
337
|
+
ingest_command,
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
return f"bash -lc {quote(script)}"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _generic_shell_command(
|
|
344
|
+
*,
|
|
345
|
+
paths: ProjectPaths,
|
|
346
|
+
prompt_path: str,
|
|
347
|
+
output_path: str,
|
|
348
|
+
ingest_command: str,
|
|
349
|
+
) -> str:
|
|
350
|
+
absolute_prompt = paths.root / prompt_path
|
|
351
|
+
absolute_output = paths.root / output_path
|
|
352
|
+
script = "\n".join(
|
|
353
|
+
[
|
|
354
|
+
"set -euo pipefail",
|
|
355
|
+
f"mkdir -p {quote(str(absolute_output.parent))}",
|
|
356
|
+
(
|
|
357
|
+
': "${PCL_AGENT_COMMAND:?Set PCL_AGENT_COMMAND to a shell command that '
|
|
358
|
+
'reads the prompt from stdin and writes agent-output/v1 Markdown to stdout.}"'
|
|
359
|
+
),
|
|
360
|
+
f"sh -c \"$PCL_AGENT_COMMAND\" < {quote(str(absolute_prompt))} > {quote(str(absolute_output))}",
|
|
361
|
+
f"test -s {quote(str(absolute_output))}",
|
|
362
|
+
ingest_command,
|
|
363
|
+
]
|
|
364
|
+
)
|
|
365
|
+
return f"bash -lc {quote(script)}"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _claude_manual_instructions(*, prompt_path: str, output_path: str, ingest_command: str) -> str:
|
|
369
|
+
return "\n".join(
|
|
370
|
+
[
|
|
371
|
+
"Claude Code manual handoff:",
|
|
372
|
+
f"1. Open or reference the full prompt at `{prompt_path}` in Claude Code.",
|
|
373
|
+
"2. Ask Claude Code to return an `agent-output/v1` Markdown report.",
|
|
374
|
+
f"3. Save Claude Code's final response exactly to `{output_path}`.",
|
|
375
|
+
f"4. Run `{ingest_command}` from the project root.",
|
|
376
|
+
"",
|
|
377
|
+
"Required output shape:",
|
|
378
|
+
"- first non-empty line: `# Short result summary`",
|
|
379
|
+
"- required heading: `## Findings`",
|
|
380
|
+
"- required heading: `## Evidence`",
|
|
381
|
+
"- recommended commands, if any, must be `pcl` commands instead of direct SQLite edits.",
|
|
382
|
+
"",
|
|
383
|
+
"Boundary:",
|
|
384
|
+
"- `pcl` does not execute Claude Code automatically.",
|
|
385
|
+
"- Do not edit `.project-loop/project.db` directly.",
|
|
386
|
+
"- Do not edit generated dashboard HTML directly.",
|
|
387
|
+
]
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _generic_shell_instructions(*, prompt_path: str, output_path: str, ingest_command: str) -> str:
|
|
392
|
+
return "\n".join(
|
|
393
|
+
[
|
|
394
|
+
"Generic shell adapter handoff:",
|
|
395
|
+
"1. Set `PCL_AGENT_COMMAND` to a shell command that reads the prompt from stdin.",
|
|
396
|
+
f"2. The generated wrapper passes `{prompt_path}` to that command through stdin.",
|
|
397
|
+
"3. The command must write an `agent-output/v1` Markdown report to stdout.",
|
|
398
|
+
f"4. The wrapper saves stdout to `{output_path}` and then runs `{ingest_command}`.",
|
|
399
|
+
"",
|
|
400
|
+
"Required output shape:",
|
|
401
|
+
"- first non-empty line: `# Short result summary`",
|
|
402
|
+
"- required heading: `## Findings`",
|
|
403
|
+
"- required heading: `## Evidence`",
|
|
404
|
+
"- recommended commands, if any, must be `pcl` commands instead of direct SQLite edits.",
|
|
405
|
+
"",
|
|
406
|
+
"Boundary:",
|
|
407
|
+
"- `pcl` only prints this wrapper; it does not execute the shell command automatically.",
|
|
408
|
+
"- Do not edit `.project-loop/project.db` directly.",
|
|
409
|
+
"- Do not edit generated dashboard HTML directly.",
|
|
410
|
+
]
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _resolve_output_path(paths: ProjectPaths, output_path: str | Path) -> Path:
|
|
415
|
+
path = Path(output_path)
|
|
416
|
+
if not path.is_absolute():
|
|
417
|
+
path = paths.root / path
|
|
418
|
+
return path.resolve()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _infer_job_id(paths: ProjectPaths, path: Path) -> str:
|
|
422
|
+
agent_runs_dir = (paths.evidence_dir / "agent-runs").resolve()
|
|
423
|
+
try:
|
|
424
|
+
relative = path.relative_to(agent_runs_dir)
|
|
425
|
+
except ValueError as exc:
|
|
426
|
+
raise InvalidInputError(
|
|
427
|
+
"Cannot infer agent job id from output path. Expected path under "
|
|
428
|
+
".project-loop/evidence/agent-runs/<job_id>/output.md.",
|
|
429
|
+
details={"path": _relative_or_absolute(paths, path)},
|
|
430
|
+
) from exc
|
|
431
|
+
if len(relative.parts) != 2 or relative.parts[1] != "output.md":
|
|
432
|
+
raise InvalidInputError(
|
|
433
|
+
"Cannot infer agent job id from output path. Expected path under "
|
|
434
|
+
".project-loop/evidence/agent-runs/<job_id>/output.md.",
|
|
435
|
+
details={"path": _relative_or_absolute(paths, path)},
|
|
436
|
+
)
|
|
437
|
+
return relative.parts[0]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _require_ingest_allowed(
|
|
441
|
+
*,
|
|
442
|
+
job_id: str,
|
|
443
|
+
job_status: str,
|
|
444
|
+
workflow_run_id: str,
|
|
445
|
+
run_status: str,
|
|
446
|
+
) -> None:
|
|
447
|
+
if job_status in {"cancelled", "failed"}:
|
|
448
|
+
raise InvalidInputError(
|
|
449
|
+
f"Agent job {job_id} cannot ingest output while status is {job_status}.",
|
|
450
|
+
details={"job_id": job_id, "status": job_status},
|
|
451
|
+
)
|
|
452
|
+
if run_status not in {"queued", "running", "blocked"}:
|
|
453
|
+
raise InvalidInputError(
|
|
454
|
+
f"Workflow run {workflow_run_id} cannot ingest agent output while status is {run_status}.",
|
|
455
|
+
details={"workflow_run_id": workflow_run_id, "status": run_status, "job_id": job_id},
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _relative_or_absolute(paths: ProjectPaths, path: Path) -> str:
|
|
460
|
+
try:
|
|
461
|
+
return str(path.relative_to(paths.root))
|
|
462
|
+
except ValueError:
|
|
463
|
+
return str(path)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _validate_output_contract(*, path: Path, display_path: str) -> dict[str, Any]:
|
|
467
|
+
text = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
468
|
+
lines = [line.strip() for line in text.splitlines()]
|
|
469
|
+
non_empty = [line for line in lines if line]
|
|
470
|
+
errors: list[str] = []
|
|
471
|
+
summary = ""
|
|
472
|
+
|
|
473
|
+
if not non_empty:
|
|
474
|
+
errors.append("Agent output is empty.")
|
|
475
|
+
else:
|
|
476
|
+
summary = non_empty[0][:200]
|
|
477
|
+
if not non_empty[0].startswith("# "):
|
|
478
|
+
errors.append("First non-empty line must be a Markdown H1 summary starting with '# '.")
|
|
479
|
+
|
|
480
|
+
line_set = set(lines)
|
|
481
|
+
for heading in REQUIRED_OUTPUT_HEADINGS:
|
|
482
|
+
if heading not in line_set:
|
|
483
|
+
errors.append(f"Missing required heading: {heading}.")
|
|
484
|
+
|
|
485
|
+
validation = {
|
|
486
|
+
"ok": not errors,
|
|
487
|
+
"contract_version": OUTPUT_CONTRACT_VERSION,
|
|
488
|
+
"required_headings": list(REQUIRED_OUTPUT_HEADINGS),
|
|
489
|
+
"summary": summary,
|
|
490
|
+
}
|
|
491
|
+
if errors:
|
|
492
|
+
validation["errors"] = errors
|
|
493
|
+
raise InvalidInputError(
|
|
494
|
+
"Agent output does not satisfy contract.",
|
|
495
|
+
details={
|
|
496
|
+
"path": display_path,
|
|
497
|
+
"contract_version": OUTPUT_CONTRACT_VERSION,
|
|
498
|
+
"errors": errors,
|
|
499
|
+
},
|
|
500
|
+
)
|
|
501
|
+
return validation
|