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.
Files changed (52) hide show
  1. pcl/__init__.py +1 -0
  2. pcl/__main__.py +4 -0
  3. pcl/agents.py +501 -0
  4. pcl/checkpoints.py +201 -0
  5. pcl/cli.py +1404 -0
  6. pcl/commands.py +1006 -0
  7. pcl/db/migrations/001_initial.sql +180 -0
  8. pcl/db/schema.sql +180 -0
  9. pcl/db.py +49 -0
  10. pcl/decisions.py +275 -0
  11. pcl/errors.py +83 -0
  12. pcl/escalations.py +302 -0
  13. pcl/events.py +41 -0
  14. pcl/evidence.py +25 -0
  15. pcl/exporters.py +77 -0
  16. pcl/guards.py +14 -0
  17. pcl/ids.py +15 -0
  18. pcl/init_project.py +112 -0
  19. pcl/lifecycle.py +1073 -0
  20. pcl/links.py +108 -0
  21. pcl/mcp_server.py +328 -0
  22. pcl/migrations.py +220 -0
  23. pcl/paths.py +65 -0
  24. pcl/renderer.py +823 -0
  25. pcl/reports.py +766 -0
  26. pcl/resources.py +26 -0
  27. pcl/stories.py +762 -0
  28. pcl/templates/dashboard/dashboard.html +165 -0
  29. pcl/templates/project/AGENTS.block.md +16 -0
  30. pcl/templates/project/CLAUDE.block.md +13 -0
  31. pcl/templates/project/gitignore.fragment +13 -0
  32. pcl/templates/project/pcl.yaml +60 -0
  33. pcl/templates/skills/project-control-loop/SKILL.md +120 -0
  34. pcl/templates/workflows/defect_repair.yaml +61 -0
  35. pcl/templates/workflows/executor_smoke.yaml +32 -0
  36. pcl/templates/workflows/feature_coverage.yaml +52 -0
  37. pcl/templates/workflows/regression_loop.yaml +51 -0
  38. pcl/timeutil.py +7 -0
  39. pcl/validators.py +788 -0
  40. pcl/workflow_executor.py +911 -0
  41. pcl/workflow_proposal_validation.py +50 -0
  42. pcl/workflow_proposals.py +442 -0
  43. pcl/workflow_sandbox.py +683 -0
  44. pcl/workflow_verifier.py +333 -0
  45. pcl/workflow_yaml.py +190 -0
  46. pcl/workflows.py +569 -0
  47. project_loop_harness-0.1.2.dist-info/METADATA +361 -0
  48. project_loop_harness-0.1.2.dist-info/RECORD +52 -0
  49. project_loop_harness-0.1.2.dist-info/WHEEL +5 -0
  50. project_loop_harness-0.1.2.dist-info/entry_points.txt +3 -0
  51. project_loop_harness-0.1.2.dist-info/licenses/LICENSE +21 -0
  52. 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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