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/cli.py ADDED
@@ -0,0 +1,1404 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sqlite3
6
+ import sys
7
+
8
+ from .agents import generate_agent_command, ingest_agent_run, read_job_prompt, read_job_prompt_handoff
9
+ from .checkpoints import checkpoint_status, record_checkpoint
10
+ from .commands import (
11
+ add_feature,
12
+ build_next_action,
13
+ create_goal,
14
+ list_features,
15
+ loop_status,
16
+ next_action,
17
+ open_defect,
18
+ read_feature,
19
+ set_feature_status,
20
+ to_pretty_json,
21
+ )
22
+ from .decisions import (
23
+ list_decisions,
24
+ open_decision,
25
+ read_decision,
26
+ resolve_decision,
27
+ waive_decision,
28
+ )
29
+ from .errors import DataStoreError, PclError
30
+ from .exporters import export_csv
31
+ from .escalations import (
32
+ cancel_escalation,
33
+ list_escalations,
34
+ open_escalation,
35
+ read_escalation,
36
+ resolve_escalation,
37
+ )
38
+ from .init_project import init_project
39
+ from .lifecycle import (
40
+ cancel_goal,
41
+ cancel_job,
42
+ cancel_workflow_run,
43
+ close_goal,
44
+ close_defect,
45
+ complete_job,
46
+ complete_workflow_run,
47
+ fail_job,
48
+ fail_workflow_run,
49
+ fix_defect,
50
+ record_verification,
51
+ start_defect,
52
+ triage_defect,
53
+ verify_defect,
54
+ waive_defect,
55
+ )
56
+ from .migrations import apply_migrations, migration_status
57
+ from .paths import resolve_paths
58
+ from .renderer import render_dashboard
59
+ from .reports import report_defect, report_feature, report_goal, report_run, report_validation
60
+ from .stories import (
61
+ approve_story,
62
+ block_test_case,
63
+ draft_story,
64
+ fail_test_case,
65
+ list_stories,
66
+ list_test_cases,
67
+ missing_test_case,
68
+ pass_test_case,
69
+ plan_test_case,
70
+ read_story,
71
+ read_test_case,
72
+ review_story,
73
+ waive_story,
74
+ waive_test_case,
75
+ )
76
+ from .validators import validate_project
77
+ from .workflow_proposals import (
78
+ PROPOSAL_STATUSES,
79
+ approve_workflow_proposal,
80
+ cancel_workflow_proposal,
81
+ list_workflow_proposals,
82
+ propose_workflow,
83
+ read_workflow_proposal,
84
+ )
85
+ from .workflow_sandbox import sandbox_workflow_file, sandbox_workflow_proposal, sandbox_workflow_template
86
+ from .workflow_verifier import verify_workflow_file, verify_workflow_proposal, verify_workflow_template
87
+ from .workflow_executor import execute_workflow
88
+ from .workflows import list_jobs, read_job, run_workflow
89
+
90
+
91
+ def build_parser() -> argparse.ArgumentParser:
92
+ parser = argparse.ArgumentParser(prog="pcl", description="Project Loop Harness CLI")
93
+ parser.add_argument("--root", default=".", help="Project root. Defaults to current directory.")
94
+ parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON output.")
95
+ sub = parser.add_subparsers(dest="command", required=True)
96
+
97
+ p_init = sub.add_parser("init", help="Initialize Project Loop Harness in a target project")
98
+ p_init.add_argument("--target", default=None, help="Target project root. Overrides --root.")
99
+ p_init.add_argument("--force", action="store_true", help="Overwrite template files where safe")
100
+ p_init.add_argument("--no-claude", action="store_true", help="Do not create/update CLAUDE.md")
101
+
102
+ p_doctor = sub.add_parser("doctor", help="Check project-loop installation health")
103
+ p_doctor.add_argument("--strict", action="store_true")
104
+
105
+ p_validate = sub.add_parser("validate", help="Validate project-loop state")
106
+ p_validate.add_argument("--strict", action="store_true")
107
+
108
+ p_migrate = sub.add_parser("migrate", help="Apply or inspect database migrations")
109
+ p_migrate.add_argument(
110
+ "migrate_action",
111
+ nargs="?",
112
+ choices=["apply", "status"],
113
+ default="apply",
114
+ help="Use `status` to inspect migrations without applying them.",
115
+ )
116
+
117
+ sub.add_parser("render", help="Render dashboard from state")
118
+
119
+ p_goal = sub.add_parser("goal", help="Manage goals")
120
+ goal_sub = p_goal.add_subparsers(dest="goal_command", required=True)
121
+ p_goal_create = goal_sub.add_parser("create")
122
+ p_goal_create.add_argument("--title", required=True)
123
+ p_goal_create.add_argument("--completion-json", default="{}")
124
+ p_goal_create.add_argument("--budget-json", default="{}")
125
+ p_goal_close = goal_sub.add_parser("close")
126
+ p_goal_close.add_argument("goal_id")
127
+ p_goal_close.add_argument("--summary", required=True)
128
+ p_goal_close.add_argument("--evidence", default="")
129
+ p_goal_close.add_argument("--verification", default=None)
130
+ p_goal_cancel = goal_sub.add_parser("cancel")
131
+ p_goal_cancel.add_argument("goal_id")
132
+ p_goal_cancel.add_argument("--summary", required=True)
133
+
134
+ p_feature = sub.add_parser("feature", help="Manage features")
135
+ feature_sub = p_feature.add_subparsers(dest="feature_command", required=True)
136
+ p_feature_add = feature_sub.add_parser("add")
137
+ p_feature_add.add_argument("--name", required=True)
138
+ p_feature_add.add_argument("--surface", required=True)
139
+ p_feature_add.add_argument("--description", default="")
140
+ p_feature_add.add_argument("--evidence", default="")
141
+ p_feature_list = feature_sub.add_parser("list")
142
+ p_feature_list.add_argument("--status", default=None)
143
+ p_feature_read = feature_sub.add_parser("read")
144
+ p_feature_read.add_argument("feature_id")
145
+ p_feature_status = feature_sub.add_parser("status")
146
+ p_feature_status.add_argument("feature_id")
147
+ p_feature_status.add_argument("--status", default="")
148
+ p_feature_status.add_argument("--summary", default="")
149
+ p_feature_status.add_argument("--evidence", default="")
150
+
151
+ p_story = sub.add_parser("story", help="Manage user stories")
152
+ story_sub = p_story.add_subparsers(dest="story_command", required=True)
153
+ p_story_draft = story_sub.add_parser("draft")
154
+ p_story_draft.add_argument("--feature", required=True)
155
+ p_story_draft.add_argument("--actor", required=True)
156
+ p_story_draft.add_argument("--goal", required=True)
157
+ p_story_draft.add_argument("--benefit", default="")
158
+ p_story_draft.add_argument("--expected-behavior", required=True)
159
+ p_story_review = story_sub.add_parser("review")
160
+ p_story_review.add_argument("story_id")
161
+ p_story_review.add_argument("--summary", required=True)
162
+ p_story_approve = story_sub.add_parser("approve")
163
+ p_story_approve.add_argument("story_id")
164
+ p_story_approve.add_argument("--summary", required=True)
165
+ p_story_waive = story_sub.add_parser("waive")
166
+ p_story_waive.add_argument("story_id")
167
+ p_story_waive.add_argument("--reason", required=True)
168
+ p_story_list = story_sub.add_parser("list")
169
+ p_story_list.add_argument("--feature", default=None)
170
+ p_story_list.add_argument("--status", default=None)
171
+ p_story_read = story_sub.add_parser("read")
172
+ p_story_read.add_argument("story_id")
173
+
174
+ p_test = sub.add_parser("test", help="Manage test cases")
175
+ test_sub = p_test.add_subparsers(dest="test_command", required=True)
176
+ p_test_plan = test_sub.add_parser("plan")
177
+ p_test_plan.add_argument("--feature", required=True)
178
+ p_test_plan.add_argument("--story", default=None)
179
+ p_test_plan.add_argument("--type", required=True)
180
+ p_test_plan.add_argument("--scenario", required=True)
181
+ p_test_plan.add_argument("--expected", required=True)
182
+ p_test_pass = test_sub.add_parser("pass")
183
+ p_test_pass.add_argument("test_case_id")
184
+ p_test_pass.add_argument("--summary", required=True)
185
+ p_test_pass.add_argument("--evidence", default="")
186
+ p_test_pass.add_argument("--run", default=None)
187
+ p_test_fail = test_sub.add_parser("fail")
188
+ p_test_fail.add_argument("test_case_id")
189
+ p_test_fail.add_argument("--summary", required=True)
190
+ p_test_fail.add_argument("--evidence", default="")
191
+ p_test_fail.add_argument("--run", default=None)
192
+ p_test_block = test_sub.add_parser("block")
193
+ p_test_block.add_argument("test_case_id")
194
+ p_test_block.add_argument("--summary", required=True)
195
+ p_test_block.add_argument("--run", default=None)
196
+ p_test_missing = test_sub.add_parser("missing")
197
+ p_test_missing.add_argument("test_case_id")
198
+ p_test_missing.add_argument("--summary", required=True)
199
+ p_test_waive = test_sub.add_parser("waive")
200
+ p_test_waive.add_argument("test_case_id")
201
+ p_test_waive.add_argument("--reason", required=True)
202
+ p_test_list = test_sub.add_parser("list")
203
+ p_test_list.add_argument("--feature", default=None)
204
+ p_test_list.add_argument("--story", default=None)
205
+ p_test_list.add_argument("--status", default=None)
206
+ p_test_read = test_sub.add_parser("read")
207
+ p_test_read.add_argument("test_case_id")
208
+
209
+ p_defect = sub.add_parser("defect", help="Manage defects")
210
+ defect_sub = p_defect.add_subparsers(dest="defect_command", required=True)
211
+ p_defect_open = defect_sub.add_parser("open")
212
+ p_defect_open.add_argument("--feature", required=True)
213
+ p_defect_open.add_argument("--severity", required=True, choices=["critical", "high", "medium", "low"])
214
+ p_defect_open.add_argument("--expected", required=True)
215
+ p_defect_open.add_argument("--actual", required=True)
216
+ p_defect_open.add_argument("--test", default=None)
217
+ p_defect_open.add_argument("--reproduction", default="")
218
+ p_defect_open.add_argument("--evidence", default="")
219
+ p_defect_triage = defect_sub.add_parser("triage")
220
+ p_defect_triage.add_argument("defect_id")
221
+ p_defect_triage.add_argument("--summary", required=True)
222
+ p_defect_start = defect_sub.add_parser("start")
223
+ p_defect_start.add_argument("defect_id")
224
+ p_defect_start.add_argument("--summary", required=True)
225
+ p_defect_fix = defect_sub.add_parser("fix")
226
+ p_defect_fix.add_argument("defect_id")
227
+ p_defect_fix.add_argument("--summary", required=True)
228
+ p_defect_fix.add_argument("--evidence", default="")
229
+ p_defect_verify = defect_sub.add_parser("verify")
230
+ p_defect_verify.add_argument("defect_id")
231
+ p_defect_verify.add_argument("--summary", required=True)
232
+ p_defect_verify.add_argument("--verification", required=True)
233
+ p_defect_close = defect_sub.add_parser("close")
234
+ p_defect_close.add_argument("defect_id")
235
+ p_defect_close.add_argument("--summary", required=True)
236
+ p_defect_close.add_argument("--evidence", default="")
237
+ p_defect_waive = defect_sub.add_parser("waive")
238
+ p_defect_waive.add_argument("defect_id")
239
+ p_defect_waive.add_argument("--reason", default="")
240
+
241
+ p_loop = sub.add_parser("loop", help="Inspect or run loops")
242
+ loop_sub = p_loop.add_subparsers(dest="loop_command", required=True)
243
+ loop_sub.add_parser("status", help="Print loop status")
244
+ p_loop_run = loop_sub.add_parser("run", help="Placeholder for workflow execution")
245
+ p_loop_run.add_argument("workflow_id")
246
+ p_loop_run.add_argument("--goal", default=None)
247
+ p_loop_run.add_argument("--defect", default=None)
248
+ p_loop_execute = loop_sub.add_parser("execute", help="Execute an approved workflow through the guarded engine")
249
+ p_loop_execute.add_argument("workflow_id")
250
+ p_loop_execute.add_argument("--goal", default=None)
251
+ p_loop_execute.add_argument("--defect", default=None)
252
+ p_loop_execute.add_argument(
253
+ "--agent-adapter",
254
+ default="manual",
255
+ choices=["manual", "generic_shell", "codex_exec"],
256
+ help="Executable adapter for agent steps. Defaults to manual, which cannot auto-execute.",
257
+ )
258
+ p_loop_execute.add_argument("--allow-agent-exec", action="store_true")
259
+ p_loop_execute.add_argument("--timeout-seconds", type=int, default=120)
260
+ p_loop_execute.add_argument("--no-auto-verify", action="store_true")
261
+ p_loop_execute.add_argument("--no-complete", action="store_true")
262
+ p_loop_execute.add_argument("--close-goal", action="store_true")
263
+ p_loop_execute.add_argument("--no-render", action="store_true")
264
+ execute_recovery = p_loop_execute.add_mutually_exclusive_group()
265
+ execute_recovery.add_argument("--retry", dest="retry_run", default=None, metavar="WR-0001")
266
+ execute_recovery.add_argument("--resume", dest="resume_run", default=None, metavar="WR-0001")
267
+ p_loop_complete = loop_sub.add_parser("complete", help="Mark a workflow run passed")
268
+ p_loop_complete.add_argument("workflow_run_id")
269
+ p_loop_complete.add_argument("--summary", required=True)
270
+ p_loop_fail = loop_sub.add_parser("fail", help="Mark a workflow run failed")
271
+ p_loop_fail.add_argument("workflow_run_id")
272
+ p_loop_fail.add_argument("--summary", required=True)
273
+ p_loop_cancel = loop_sub.add_parser("cancel", help="Cancel a workflow run and its active jobs")
274
+ p_loop_cancel.add_argument("workflow_run_id")
275
+ p_loop_cancel.add_argument("--summary", required=True)
276
+
277
+ p_workflow = sub.add_parser("workflow", help="Manage workflow proposals")
278
+ workflow_sub = p_workflow.add_subparsers(dest="workflow_command", required=True)
279
+ p_workflow_propose = workflow_sub.add_parser("propose", help="Store a workflow proposal for review")
280
+ p_workflow_propose.add_argument("--file", required=True, help="Workflow YAML file to propose")
281
+ p_workflow_propose.add_argument("--summary", default="")
282
+ p_workflow_verify = workflow_sub.add_parser("verify", help="Verify a workflow file, proposal, or template")
283
+ workflow_verify_target = p_workflow_verify.add_mutually_exclusive_group(required=True)
284
+ workflow_verify_target.add_argument("--file", default=None, help="Workflow YAML file to verify")
285
+ workflow_verify_target.add_argument("--proposal", default=None, help="Workflow proposal id to verify")
286
+ workflow_verify_target.add_argument("--template", default=None, help="Approved workflow template id to verify")
287
+ p_workflow_sandbox = workflow_sub.add_parser("sandbox", help="Plan or execute allowlisted workflow commands")
288
+ workflow_sandbox_target = p_workflow_sandbox.add_mutually_exclusive_group(required=True)
289
+ workflow_sandbox_target.add_argument("--file", default=None, help="Workflow YAML file to sandbox-plan")
290
+ workflow_sandbox_target.add_argument("--proposal", default=None, help="Workflow proposal id to sandbox-plan")
291
+ workflow_sandbox_target.add_argument("--template", default=None, help="Approved workflow template id to sandbox")
292
+ p_workflow_sandbox.add_argument("--execute", action="store_true", help="Run sandbox-safe commands")
293
+ p_workflow_sandbox.add_argument("--timeout-seconds", type=int, default=120)
294
+ p_workflow_proposals = workflow_sub.add_parser("proposals", help="Inspect workflow proposals")
295
+ proposals_sub = p_workflow_proposals.add_subparsers(dest="workflow_proposals_command", required=True)
296
+ p_workflow_proposals_list = proposals_sub.add_parser("list", help="List workflow proposals")
297
+ p_workflow_proposals_list.add_argument("--status", choices=sorted(PROPOSAL_STATUSES), default=None)
298
+ p_workflow_proposals_read = proposals_sub.add_parser("read", help="Read a workflow proposal")
299
+ p_workflow_proposals_read.add_argument("proposal_id")
300
+ p_workflow_proposals_approve = proposals_sub.add_parser("approve", help="Approve a workflow proposal")
301
+ p_workflow_proposals_approve.add_argument("proposal_id")
302
+ p_workflow_proposals_approve.add_argument("--summary", required=True)
303
+ p_workflow_proposals_cancel = proposals_sub.add_parser("cancel", help="Cancel a workflow proposal")
304
+ p_workflow_proposals_cancel.add_argument("proposal_id")
305
+ p_workflow_proposals_cancel.add_argument("--summary", required=True)
306
+
307
+ p_jobs = sub.add_parser("jobs", help="Inspect agent jobs")
308
+ jobs_sub = p_jobs.add_subparsers(dest="jobs_command", required=True)
309
+ p_jobs_list = jobs_sub.add_parser("list", help="List agent jobs")
310
+ p_jobs_list.add_argument("--run", default=None, help="Filter jobs by workflow run id")
311
+ p_jobs_list.add_argument(
312
+ "--status",
313
+ choices=["queued", "running", "blocked", "failed", "passed", "cancelled"],
314
+ default=None,
315
+ help="Filter jobs by job status",
316
+ )
317
+ p_jobs_read = jobs_sub.add_parser("read", help="Read an agent job prompt")
318
+ p_jobs_read.add_argument("job_id")
319
+ p_jobs_complete = jobs_sub.add_parser("complete", help="Mark an agent job passed")
320
+ p_jobs_complete.add_argument("job_id")
321
+ p_jobs_complete.add_argument("--summary", required=True)
322
+ p_jobs_complete.add_argument("--output", default=None)
323
+ p_jobs_complete.add_argument("--token-input", type=int, default=None)
324
+ p_jobs_complete.add_argument("--token-output", type=int, default=None)
325
+ p_jobs_fail = jobs_sub.add_parser("fail", help="Mark an agent job failed")
326
+ p_jobs_fail.add_argument("job_id")
327
+ p_jobs_fail.add_argument("--summary", required=True)
328
+ p_jobs_cancel = jobs_sub.add_parser("cancel", help="Cancel an agent job")
329
+ p_jobs_cancel.add_argument("job_id")
330
+ p_jobs_cancel.add_argument("--summary", required=True)
331
+
332
+ p_prompt = sub.add_parser("prompt", help="Print generated prompts")
333
+ prompt_sub = p_prompt.add_subparsers(dest="prompt_command", required=True)
334
+ p_prompt_job = prompt_sub.add_parser("job", help="Print one agent job prompt")
335
+ p_prompt_job.add_argument("job_id")
336
+
337
+ p_agent = sub.add_parser("agent", help="Generate agent adapter commands")
338
+ agent_sub = p_agent.add_subparsers(dest="agent_command", required=True)
339
+ p_agent_command = agent_sub.add_parser("command", help="Print an adapter command for a job")
340
+ p_agent_command.add_argument("job_id")
341
+ p_agent_command.add_argument(
342
+ "--adapter",
343
+ default="manual",
344
+ choices=["manual", "codex_exec", "claude_manual", "generic_shell"],
345
+ help="Agent adapter to use. Defaults to manual.",
346
+ )
347
+
348
+ p_ingest = sub.add_parser("ingest-agent-run", help="Record an agent output file as evidence")
349
+ p_ingest.add_argument("path")
350
+
351
+ p_verification = sub.add_parser("verification", help="Record verification results")
352
+ verification_sub = p_verification.add_subparsers(dest="verification_command", required=True)
353
+ p_verification_record = verification_sub.add_parser("record")
354
+ p_verification_record.add_argument("--run", required=True)
355
+ p_verification_record.add_argument("--target-job", default=None)
356
+ p_verification_record.add_argument(
357
+ "--result",
358
+ required=True,
359
+ choices=["approved", "rejected", "needs_human", "inconclusive"],
360
+ )
361
+ p_verification_record.add_argument("--verifier-role", default="human")
362
+ p_verification_record.add_argument("--rubric-json", default="{}")
363
+ p_verification_record.add_argument("--reason", action="append", required=True)
364
+
365
+ p_decision = sub.add_parser("decision", help="Manage human decisions")
366
+ decision_sub = p_decision.add_subparsers(dest="decision_command", required=True)
367
+ p_decision_open = decision_sub.add_parser("open")
368
+ p_decision_open.add_argument("--question", required=True)
369
+ p_decision_open.add_argument("--recommendation", required=True)
370
+ p_decision_open.add_argument("--blocks-json", default="[]")
371
+ p_decision_open.add_argument("--escalation", default=None)
372
+ p_decision_resolve = decision_sub.add_parser("resolve")
373
+ p_decision_resolve.add_argument("decision_id")
374
+ p_decision_resolve.add_argument("--selected-option", required=True)
375
+ p_decision_resolve.add_argument("--reason", required=True)
376
+ p_decision_waive = decision_sub.add_parser("waive")
377
+ p_decision_waive.add_argument("decision_id")
378
+ p_decision_waive.add_argument("--reason", required=True)
379
+ p_decision_list = decision_sub.add_parser("list")
380
+ p_decision_list.add_argument("--status", choices=["open", "resolved", "waived"], default=None)
381
+ p_decision_read = decision_sub.add_parser("read")
382
+ p_decision_read.add_argument("decision_id")
383
+
384
+ p_escalation = sub.add_parser("escalation", help="Manage human escalations")
385
+ escalation_sub = p_escalation.add_subparsers(dest="escalation_command", required=True)
386
+ p_escalation_open = escalation_sub.add_parser("open")
387
+ p_escalation_open.add_argument("--severity", required=True, choices=["critical", "high", "medium", "low"])
388
+ p_escalation_open.add_argument("--question", required=True)
389
+ p_escalation_open.add_argument("--recommendation", default="")
390
+ p_escalation_open.add_argument("--run", default=None)
391
+ p_escalation_resolve = escalation_sub.add_parser("resolve")
392
+ p_escalation_resolve.add_argument("escalation_id")
393
+ p_escalation_resolve.add_argument("--summary", required=True)
394
+ p_escalation_resolve.add_argument("--decision", default=None)
395
+ p_escalation_cancel = escalation_sub.add_parser("cancel")
396
+ p_escalation_cancel.add_argument("escalation_id")
397
+ p_escalation_cancel.add_argument("--summary", required=True)
398
+ p_escalation_list = escalation_sub.add_parser("list")
399
+ p_escalation_list.add_argument("--status", choices=["open", "resolved", "cancelled"], default=None)
400
+ p_escalation_read = escalation_sub.add_parser("read")
401
+ p_escalation_read.add_argument("escalation_id")
402
+
403
+ p_checkpoint = sub.add_parser("checkpoint", help="Record and inspect integration checkpoints")
404
+ checkpoint_sub = p_checkpoint.add_subparsers(dest="checkpoint_command", required=True)
405
+ checkpoint_sub.add_parser("status", help="Inspect checkpoint recommendation state")
406
+ p_checkpoint_record = checkpoint_sub.add_parser("record", help="Record a human integration checkpoint")
407
+ p_checkpoint_record.add_argument("--summary", required=True)
408
+ p_checkpoint_record.add_argument("--evidence", required=True)
409
+ p_checkpoint_record.add_argument("--review-type", default="integration")
410
+
411
+ p_next = sub.add_parser("next", help="Suggest the next harness action")
412
+ p_next.add_argument("--strict", action="store_true", help="Route strict validation failures before normal next actions")
413
+ p_next.add_argument("--explain", action="store_true", help="Print a human-readable explanation of the next action")
414
+
415
+ p_export = sub.add_parser("export", help="Export state")
416
+ export_sub = p_export.add_subparsers(dest="export_command", required=True)
417
+ export_sub.add_parser("csv")
418
+
419
+ p_report = sub.add_parser("report", help="Generate evidence reports")
420
+ report_sub = p_report.add_subparsers(dest="report_command", required=True)
421
+ p_report_goal = report_sub.add_parser("goal")
422
+ p_report_goal.add_argument("goal_id")
423
+ p_report_run = report_sub.add_parser("run")
424
+ p_report_run.add_argument("workflow_run_id")
425
+ p_report_feature = report_sub.add_parser("feature")
426
+ p_report_feature.add_argument("feature_id")
427
+ p_report_defect = report_sub.add_parser("defect")
428
+ p_report_defect.add_argument("defect_id")
429
+ p_report_validation = report_sub.add_parser("validation")
430
+ p_report_validation.add_argument("--strict", action="store_true")
431
+
432
+ return parser
433
+
434
+
435
+ def _print_json(payload: object) -> None:
436
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
437
+
438
+
439
+ def _format_next_explanation(action: dict) -> str:
440
+ lines = [
441
+ f"Next action: {action.get('type', '')}",
442
+ f"Priority: {action.get('priority', '')}",
443
+ f"Blocking: {_yes_no(bool(action.get('blocking')))}",
444
+ f"Requires human: {_yes_no(bool(action.get('requires_human')))}",
445
+ f"Safe to run: {_yes_no(bool(action.get('safe_to_run')))}",
446
+ f"Run policy: {action.get('run_policy', '')}",
447
+ f"Human guidance: {action.get('human_guidance', '')}",
448
+ f"Reason: {action.get('reason', '')}",
449
+ f"Command: {action.get('command', '')}",
450
+ f"Expected after: {action.get('expected_after', '')}",
451
+ ]
452
+ target = action.get("target")
453
+ if isinstance(target, dict) and target.get("id"):
454
+ lines.append(f"Target: {target['id']}")
455
+ return "\n".join(lines)
456
+
457
+
458
+ def _yes_no(value: bool) -> str:
459
+ return "yes" if value else "no"
460
+
461
+
462
+ def _print_validation(result, *, json_output: bool = False) -> int:
463
+ if json_output:
464
+ _print_json(result.to_dict())
465
+ return 0 if result.ok else 1
466
+
467
+ for warning in result.warnings:
468
+ print(f"WARNING: {warning}")
469
+ for error in result.errors:
470
+ print(f"ERROR: {error}")
471
+ if result.ok:
472
+ print("OK")
473
+ return 0
474
+ return 1
475
+
476
+
477
+ def _print_error(error: PclError, *, json_output: bool = False) -> None:
478
+ if json_output:
479
+ _print_json(error.to_dict())
480
+ return
481
+ print(f"ERROR: {error}", file=sys.stderr)
482
+ detail_errors = error.details.get("errors")
483
+ if isinstance(detail_errors, list):
484
+ for detail in detail_errors:
485
+ print(f"ERROR: {detail}", file=sys.stderr)
486
+ detail_warnings = error.details.get("warnings")
487
+ if isinstance(detail_warnings, list):
488
+ for detail in detail_warnings:
489
+ print(f"WARNING: {detail}", file=sys.stderr)
490
+
491
+
492
+ def _extract_global_options(argv: list[str] | None) -> tuple[list[str] | None, str | None, bool]:
493
+ """Allow global options before or after subcommands for agent-friendliness.
494
+
495
+ argparse normally requires global options before the subcommand. Coding agents
496
+ often place --root/--json at the end, so we normalize them here.
497
+ """
498
+ if argv is None:
499
+ argv = sys.argv[1:]
500
+ normalized: list[str] = []
501
+ root_override: str | None = None
502
+ json_output = False
503
+ i = 0
504
+ while i < len(argv):
505
+ token = argv[i]
506
+ if token == "--root" and i + 1 < len(argv):
507
+ root_override = argv[i + 1]
508
+ i += 2
509
+ continue
510
+ if token.startswith("--root="):
511
+ root_override = token.split("=", 1)[1]
512
+ i += 1
513
+ continue
514
+ if token == "--json":
515
+ json_output = True
516
+ i += 1
517
+ continue
518
+ normalized.append(token)
519
+ i += 1
520
+ return normalized, root_override, json_output
521
+
522
+
523
+ def main(argv: list[str] | None = None) -> int:
524
+ argv, root_override, json_override = _extract_global_options(argv)
525
+ parser = build_parser()
526
+ args = parser.parse_args(argv)
527
+ root = getattr(args, "target", None) or root_override or args.root
528
+ paths = resolve_paths(root)
529
+ json_output = json_override or args.json
530
+
531
+ try:
532
+ if args.command == "init":
533
+ result = init_project(paths, overwrite=args.force, with_claude=not args.no_claude)
534
+ if json_output:
535
+ _print_json(
536
+ {
537
+ "ok": True,
538
+ "root": str(result.root),
539
+ "created": result.created,
540
+ "event_appended": result.event_appended,
541
+ }
542
+ )
543
+ else:
544
+ print(f"Initialized Project Loop Harness at {paths.root}")
545
+ return 0
546
+
547
+ if args.command in {"doctor", "validate"}:
548
+ result = validate_project(paths, strict=args.strict)
549
+ return _print_validation(result, json_output=json_output)
550
+
551
+ if args.command == "migrate":
552
+ if args.migrate_action == "status":
553
+ status = migration_status(paths)
554
+ payload = {"ok": True, **status.to_dict()}
555
+ if json_output:
556
+ _print_json(payload)
557
+ else:
558
+ print(to_pretty_json(payload))
559
+ return 0
560
+ result = apply_migrations(paths)
561
+ if json_output:
562
+ _print_json(result.to_dict())
563
+ elif result.applied:
564
+ for migration in result.applied:
565
+ print(f"Applied migration {migration.id}")
566
+ else:
567
+ print("No pending migrations")
568
+ return 0
569
+
570
+ if args.command == "render":
571
+ result = validate_project(paths)
572
+ if not result.ok:
573
+ return _print_validation(result, json_output=json_output)
574
+ render_dashboard(paths)
575
+ if json_output:
576
+ _print_json(
577
+ {
578
+ "data_path": str(paths.dashboard_data),
579
+ "ok": True,
580
+ "path": str(paths.dashboard_html),
581
+ }
582
+ )
583
+ else:
584
+ print(f"Rendered {paths.dashboard_html}")
585
+ return 0
586
+
587
+ if args.command == "goal" and args.goal_command == "create":
588
+ goal_id = create_goal(
589
+ paths,
590
+ title=args.title,
591
+ completion_json=args.completion_json,
592
+ budget_json=args.budget_json,
593
+ )
594
+ if json_output:
595
+ _print_json({"id": goal_id, "ok": True})
596
+ else:
597
+ print(goal_id)
598
+ return 0
599
+
600
+ if args.command == "goal" and args.goal_command == "close":
601
+ result = close_goal(
602
+ paths,
603
+ goal_id=args.goal_id,
604
+ summary=args.summary,
605
+ evidence=args.evidence,
606
+ verification_id=args.verification,
607
+ )
608
+ if json_output:
609
+ _print_json(result)
610
+ else:
611
+ print(f"Closed goal {result['goal_id']}")
612
+ return 0
613
+
614
+ if args.command == "goal" and args.goal_command == "cancel":
615
+ result = cancel_goal(paths, goal_id=args.goal_id, summary=args.summary)
616
+ if json_output:
617
+ _print_json(result)
618
+ else:
619
+ print(f"Cancelled goal {result['goal_id']}")
620
+ return 0
621
+
622
+ if args.command == "feature" and args.feature_command == "add":
623
+ feature_id = add_feature(
624
+ paths,
625
+ name=args.name,
626
+ surface=args.surface,
627
+ description=args.description,
628
+ evidence=args.evidence,
629
+ )
630
+ if json_output:
631
+ _print_json({"id": feature_id, "ok": True})
632
+ else:
633
+ print(feature_id)
634
+ return 0
635
+
636
+ if args.command == "feature" and args.feature_command == "list":
637
+ features = list_features(paths, status=args.status)
638
+ if json_output:
639
+ _print_json({"features": features, "ok": True})
640
+ elif features:
641
+ for feature in features:
642
+ print(f"{feature['id']} {feature['status']} surface={feature['surface']} name={feature['name']}")
643
+ else:
644
+ print("No features")
645
+ return 0
646
+
647
+ if args.command == "feature" and args.feature_command == "read":
648
+ feature = read_feature(paths, args.feature_id)
649
+ if json_output:
650
+ _print_json({"feature": feature, "ok": True})
651
+ else:
652
+ print(to_pretty_json(feature))
653
+ return 0
654
+
655
+ if args.command == "feature" and args.feature_command == "status":
656
+ result = set_feature_status(
657
+ paths,
658
+ args.feature_id,
659
+ status=args.status,
660
+ summary=args.summary,
661
+ evidence=args.evidence,
662
+ )
663
+ if json_output:
664
+ _print_json(result)
665
+ else:
666
+ print(f"Updated feature {result['feature_id']} to {result['status']}")
667
+ return 0
668
+
669
+ if args.command == "story" and args.story_command == "draft":
670
+ result = draft_story(
671
+ paths,
672
+ feature_id=args.feature,
673
+ actor=args.actor,
674
+ goal=args.goal,
675
+ benefit=args.benefit,
676
+ expected_behavior=args.expected_behavior,
677
+ )
678
+ if json_output:
679
+ _print_json(result)
680
+ else:
681
+ print(result["id"])
682
+ return 0
683
+
684
+ if args.command == "story" and args.story_command == "review":
685
+ result = review_story(paths, story_id=args.story_id, summary=args.summary)
686
+ if json_output:
687
+ _print_json(result)
688
+ else:
689
+ print(f"Reviewed story {result['id']}")
690
+ return 0
691
+
692
+ if args.command == "story" and args.story_command == "approve":
693
+ result = approve_story(paths, story_id=args.story_id, summary=args.summary)
694
+ if json_output:
695
+ _print_json(result)
696
+ else:
697
+ print(f"Approved story {result['id']}")
698
+ return 0
699
+
700
+ if args.command == "story" and args.story_command == "waive":
701
+ result = waive_story(paths, story_id=args.story_id, reason=args.reason)
702
+ if json_output:
703
+ _print_json(result)
704
+ else:
705
+ print(f"Waived story {result['id']}")
706
+ return 0
707
+
708
+ if args.command == "story" and args.story_command == "list":
709
+ stories = list_stories(paths, feature_id=args.feature, status=args.status)
710
+ if json_output:
711
+ _print_json({"ok": True, "stories": stories})
712
+ elif stories:
713
+ for story in stories:
714
+ print(f"{story['id']} {story['status']} feature={story['feature_id']} goal={story['goal']}")
715
+ else:
716
+ print("No stories")
717
+ return 0
718
+
719
+ if args.command == "story" and args.story_command == "read":
720
+ story = read_story(paths, args.story_id)
721
+ if json_output:
722
+ _print_json({"ok": True, "story": story})
723
+ else:
724
+ print(to_pretty_json(story))
725
+ return 0
726
+
727
+ if args.command == "test" and args.test_command == "plan":
728
+ result = plan_test_case(
729
+ paths,
730
+ feature_id=args.feature,
731
+ story_id=args.story,
732
+ test_type=args.type,
733
+ scenario=args.scenario,
734
+ expected=args.expected,
735
+ )
736
+ if json_output:
737
+ _print_json(result)
738
+ else:
739
+ print(result["id"])
740
+ return 0
741
+
742
+ if args.command == "test" and args.test_command == "pass":
743
+ result = pass_test_case(
744
+ paths,
745
+ test_case_id=args.test_case_id,
746
+ summary=args.summary,
747
+ evidence=args.evidence,
748
+ workflow_run_id=args.run,
749
+ )
750
+ if json_output:
751
+ _print_json(result)
752
+ else:
753
+ print(f"Passed test case {result['id']}")
754
+ return 0
755
+
756
+ if args.command == "test" and args.test_command == "fail":
757
+ result = fail_test_case(
758
+ paths,
759
+ test_case_id=args.test_case_id,
760
+ summary=args.summary,
761
+ evidence=args.evidence,
762
+ workflow_run_id=args.run,
763
+ )
764
+ if json_output:
765
+ _print_json(result)
766
+ else:
767
+ print(f"Failed test case {result['id']}")
768
+ return 0
769
+
770
+ if args.command == "test" and args.test_command == "block":
771
+ result = block_test_case(
772
+ paths,
773
+ test_case_id=args.test_case_id,
774
+ summary=args.summary,
775
+ workflow_run_id=args.run,
776
+ )
777
+ if json_output:
778
+ _print_json(result)
779
+ else:
780
+ print(f"Blocked test case {result['id']}")
781
+ return 0
782
+
783
+ if args.command == "test" and args.test_command == "missing":
784
+ result = missing_test_case(paths, test_case_id=args.test_case_id, summary=args.summary)
785
+ if json_output:
786
+ _print_json(result)
787
+ else:
788
+ print(f"Marked test case {result['id']} missing")
789
+ return 0
790
+
791
+ if args.command == "test" and args.test_command == "waive":
792
+ result = waive_test_case(paths, test_case_id=args.test_case_id, reason=args.reason)
793
+ if json_output:
794
+ _print_json(result)
795
+ else:
796
+ print(f"Waived test case {result['id']}")
797
+ return 0
798
+
799
+ if args.command == "test" and args.test_command == "list":
800
+ test_cases = list_test_cases(
801
+ paths,
802
+ feature_id=args.feature,
803
+ story_id=args.story,
804
+ status=args.status,
805
+ )
806
+ if json_output:
807
+ _print_json({"ok": True, "test_cases": test_cases})
808
+ elif test_cases:
809
+ for test_case in test_cases:
810
+ print(
811
+ f"{test_case['id']} {test_case['status']} feature={test_case['feature_id']} "
812
+ f"type={test_case['type']}"
813
+ )
814
+ else:
815
+ print("No test cases")
816
+ return 0
817
+
818
+ if args.command == "test" and args.test_command == "read":
819
+ test_case = read_test_case(paths, args.test_case_id)
820
+ if json_output:
821
+ _print_json({"ok": True, "test_case": test_case})
822
+ else:
823
+ print(to_pretty_json(test_case))
824
+ return 0
825
+
826
+ if args.command == "defect" and args.defect_command == "open":
827
+ defect_id = open_defect(
828
+ paths,
829
+ feature_id=args.feature,
830
+ severity=args.severity,
831
+ expected=args.expected,
832
+ actual=args.actual,
833
+ test_case_id=args.test,
834
+ reproduction=args.reproduction,
835
+ evidence=args.evidence,
836
+ )
837
+ if json_output:
838
+ _print_json({"id": defect_id, "ok": True})
839
+ else:
840
+ print(defect_id)
841
+ return 0
842
+
843
+ if args.command == "defect" and args.defect_command == "triage":
844
+ result = triage_defect(paths, defect_id=args.defect_id, summary=args.summary)
845
+ if json_output:
846
+ _print_json(result)
847
+ else:
848
+ print(f"Triaged defect {result['defect_id']}")
849
+ return 0
850
+
851
+ if args.command == "defect" and args.defect_command == "start":
852
+ result = start_defect(paths, defect_id=args.defect_id, summary=args.summary)
853
+ if json_output:
854
+ _print_json(result)
855
+ else:
856
+ print(f"Started defect {result['defect_id']}")
857
+ return 0
858
+
859
+ if args.command == "defect" and args.defect_command == "fix":
860
+ result = fix_defect(
861
+ paths,
862
+ defect_id=args.defect_id,
863
+ summary=args.summary,
864
+ evidence=args.evidence,
865
+ )
866
+ if json_output:
867
+ _print_json(result)
868
+ else:
869
+ print(f"Fixed defect {result['defect_id']}")
870
+ return 0
871
+
872
+ if args.command == "defect" and args.defect_command == "verify":
873
+ result = verify_defect(
874
+ paths,
875
+ defect_id=args.defect_id,
876
+ summary=args.summary,
877
+ verification_id=args.verification,
878
+ )
879
+ if json_output:
880
+ _print_json(result)
881
+ else:
882
+ print(f"Verified defect {result['defect_id']}")
883
+ return 0
884
+
885
+ if args.command == "defect" and args.defect_command == "close":
886
+ result = close_defect(
887
+ paths,
888
+ defect_id=args.defect_id,
889
+ summary=args.summary,
890
+ evidence=args.evidence,
891
+ )
892
+ if json_output:
893
+ _print_json(result)
894
+ else:
895
+ print(f"Closed defect {result['defect_id']}")
896
+ return 0
897
+
898
+ if args.command == "defect" and args.defect_command == "waive":
899
+ result = waive_defect(paths, defect_id=args.defect_id, reason=args.reason)
900
+ if json_output:
901
+ _print_json(result)
902
+ else:
903
+ print(f"Waived defect {result['defect_id']}")
904
+ return 0
905
+
906
+ if args.command == "loop" and args.loop_command == "status":
907
+ status = loop_status(paths)
908
+ if json_output:
909
+ _print_json(status)
910
+ else:
911
+ print(to_pretty_json(status))
912
+ return 0
913
+
914
+ if args.command == "loop" and args.loop_command == "run":
915
+ result = run_workflow(
916
+ paths,
917
+ workflow_id=args.workflow_id,
918
+ goal_id=args.goal,
919
+ defect_id=args.defect,
920
+ )
921
+ if json_output:
922
+ _print_json(result)
923
+ else:
924
+ run = result["workflow_run"]
925
+ print(f"Created workflow run {run['id']} for {run['workflow_id']}")
926
+ for job in result["jobs"]:
927
+ print(f"Queued job {job['id']} role={job['role']} prompt={job['prompt_path']}")
928
+ return 0
929
+
930
+ if args.command == "loop" and args.loop_command == "execute":
931
+ result = execute_workflow(
932
+ paths,
933
+ workflow_id=args.workflow_id,
934
+ goal_id=args.goal,
935
+ defect_id=args.defect,
936
+ agent_adapter=args.agent_adapter,
937
+ allow_agent_exec=args.allow_agent_exec,
938
+ timeout_seconds=args.timeout_seconds,
939
+ auto_verify=not args.no_auto_verify,
940
+ complete=not args.no_complete,
941
+ close_goal_on_complete=args.close_goal,
942
+ render=not args.no_render,
943
+ retry_run_id=args.retry_run,
944
+ resume_run_id=args.resume_run,
945
+ )
946
+ if json_output:
947
+ _print_json(result)
948
+ else:
949
+ print(to_pretty_json(result))
950
+ return 0 if result["ok"] else 1
951
+
952
+ if args.command == "loop" and args.loop_command == "complete":
953
+ result = complete_workflow_run(paths, workflow_run_id=args.workflow_run_id, summary=args.summary)
954
+ if json_output:
955
+ _print_json(result)
956
+ else:
957
+ print(f"Completed workflow run {result['workflow_run_id']}")
958
+ return 0
959
+
960
+ if args.command == "loop" and args.loop_command == "fail":
961
+ result = fail_workflow_run(paths, workflow_run_id=args.workflow_run_id, summary=args.summary)
962
+ if json_output:
963
+ _print_json(result)
964
+ else:
965
+ print(f"Failed workflow run {result['workflow_run_id']}")
966
+ return 0
967
+
968
+ if args.command == "loop" and args.loop_command == "cancel":
969
+ result = cancel_workflow_run(paths, workflow_run_id=args.workflow_run_id, summary=args.summary)
970
+ if json_output:
971
+ _print_json(result)
972
+ else:
973
+ print(f"Cancelled workflow run {result['workflow_run_id']}")
974
+ return 0
975
+
976
+ if args.command == "workflow" and args.workflow_command == "propose":
977
+ result = propose_workflow(paths, source_path=args.file, summary=args.summary)
978
+ if json_output:
979
+ _print_json(result)
980
+ else:
981
+ print(result["id"])
982
+ return 0
983
+
984
+ if args.command == "workflow" and args.workflow_command == "verify":
985
+ if args.file:
986
+ result = verify_workflow_file(paths, source_path=args.file)
987
+ elif args.proposal:
988
+ result = verify_workflow_proposal(paths, proposal_id=args.proposal)
989
+ else:
990
+ result = verify_workflow_template(paths, workflow_id=args.template)
991
+ payload = {"ok": result["ok"], "verification": result}
992
+ if json_output:
993
+ _print_json(payload)
994
+ else:
995
+ print(to_pretty_json(payload))
996
+ return 0 if result["ok"] else 1
997
+
998
+ if args.command == "workflow" and args.workflow_command == "sandbox":
999
+ if args.file:
1000
+ result = sandbox_workflow_file(
1001
+ paths,
1002
+ source_path=args.file,
1003
+ execute=args.execute,
1004
+ timeout_seconds=args.timeout_seconds,
1005
+ )
1006
+ elif args.proposal:
1007
+ result = sandbox_workflow_proposal(
1008
+ paths,
1009
+ proposal_id=args.proposal,
1010
+ execute=args.execute,
1011
+ timeout_seconds=args.timeout_seconds,
1012
+ )
1013
+ else:
1014
+ result = sandbox_workflow_template(
1015
+ paths,
1016
+ workflow_id=args.template,
1017
+ execute=args.execute,
1018
+ timeout_seconds=args.timeout_seconds,
1019
+ )
1020
+ if json_output:
1021
+ _print_json(result)
1022
+ else:
1023
+ print(to_pretty_json(result))
1024
+ return 0 if result["ok"] else 1
1025
+
1026
+ if (
1027
+ args.command == "workflow"
1028
+ and args.workflow_command == "proposals"
1029
+ and args.workflow_proposals_command == "list"
1030
+ ):
1031
+ proposals = list_workflow_proposals(paths, status=args.status)
1032
+ if json_output:
1033
+ _print_json({"ok": True, "proposals": proposals})
1034
+ elif proposals:
1035
+ for proposal in proposals:
1036
+ print(
1037
+ f"{proposal['id']} workflow={proposal['workflow_id']} "
1038
+ f"path={proposal['path']}"
1039
+ )
1040
+ else:
1041
+ print("No workflow proposals")
1042
+ return 0
1043
+
1044
+ if (
1045
+ args.command == "workflow"
1046
+ and args.workflow_command == "proposals"
1047
+ and args.workflow_proposals_command == "read"
1048
+ ):
1049
+ proposal = read_workflow_proposal(paths, args.proposal_id)
1050
+ if json_output:
1051
+ _print_json({"ok": True, "proposal": proposal})
1052
+ else:
1053
+ print(to_pretty_json(proposal))
1054
+ return 0
1055
+
1056
+ if (
1057
+ args.command == "workflow"
1058
+ and args.workflow_command == "proposals"
1059
+ and args.workflow_proposals_command == "approve"
1060
+ ):
1061
+ result = approve_workflow_proposal(paths, args.proposal_id, summary=args.summary)
1062
+ if json_output:
1063
+ _print_json(result)
1064
+ else:
1065
+ print(f"Approved workflow proposal {result['id']} as {result['workflow_path']}")
1066
+ return 0
1067
+
1068
+ if (
1069
+ args.command == "workflow"
1070
+ and args.workflow_command == "proposals"
1071
+ and args.workflow_proposals_command == "cancel"
1072
+ ):
1073
+ result = cancel_workflow_proposal(paths, args.proposal_id, summary=args.summary)
1074
+ if json_output:
1075
+ _print_json(result)
1076
+ else:
1077
+ print(f"Cancelled workflow proposal {result['id']}")
1078
+ return 0
1079
+
1080
+ if args.command == "jobs" and args.jobs_command == "list":
1081
+ jobs = list_jobs(paths, workflow_run_id=args.run, status=args.status)
1082
+ if json_output:
1083
+ _print_json({"ok": True, "jobs": jobs})
1084
+ elif jobs:
1085
+ for job in jobs:
1086
+ print(
1087
+ f"{job['id']} {job['status']} workflow={job['workflow_id']} "
1088
+ f"run={job['workflow_run_id']} role={job['role']}"
1089
+ )
1090
+ else:
1091
+ print("No agent jobs")
1092
+ return 0
1093
+
1094
+ if args.command == "jobs" and args.jobs_command == "read":
1095
+ job = read_job(paths, args.job_id)
1096
+ if json_output:
1097
+ _print_json({"ok": True, "job": job})
1098
+ else:
1099
+ print(job["prompt"])
1100
+ return 0
1101
+
1102
+ if args.command == "jobs" and args.jobs_command == "complete":
1103
+ result = complete_job(
1104
+ paths,
1105
+ job_id=args.job_id,
1106
+ summary=args.summary,
1107
+ output_path=args.output,
1108
+ token_input=args.token_input,
1109
+ token_output=args.token_output,
1110
+ )
1111
+ if json_output:
1112
+ _print_json(result)
1113
+ else:
1114
+ print(f"Completed job {result['job_id']}")
1115
+ return 0
1116
+
1117
+ if args.command == "jobs" and args.jobs_command == "fail":
1118
+ result = fail_job(paths, job_id=args.job_id, summary=args.summary)
1119
+ if json_output:
1120
+ _print_json(result)
1121
+ else:
1122
+ print(f"Failed job {result['job_id']}")
1123
+ return 0
1124
+
1125
+ if args.command == "jobs" and args.jobs_command == "cancel":
1126
+ result = cancel_job(paths, job_id=args.job_id, summary=args.summary)
1127
+ if json_output:
1128
+ _print_json(result)
1129
+ else:
1130
+ print(f"Cancelled job {result['job_id']}")
1131
+ return 0
1132
+
1133
+ if args.command == "prompt" and args.prompt_command == "job":
1134
+ if json_output:
1135
+ _print_json(read_job_prompt_handoff(paths, args.job_id))
1136
+ else:
1137
+ prompt = read_job_prompt(paths, args.job_id)
1138
+ print(prompt)
1139
+ return 0
1140
+
1141
+ if args.command == "agent" and args.agent_command == "command":
1142
+ command = generate_agent_command(paths, args.job_id, args.adapter)
1143
+ if json_output:
1144
+ _print_json({"ok": True, "agent_command": command.to_dict()})
1145
+ else:
1146
+ if command.command:
1147
+ print(command.command)
1148
+ else:
1149
+ print(command.instructions)
1150
+ return 0
1151
+
1152
+ if args.command == "ingest-agent-run":
1153
+ result = ingest_agent_run(paths, args.path)
1154
+ if json_output:
1155
+ _print_json(result)
1156
+ else:
1157
+ print(
1158
+ f"Ingested {result['output_path']} as {result['evidence_id']} "
1159
+ f"for job {result['job_id']}"
1160
+ )
1161
+ return 0
1162
+
1163
+ if args.command == "verification" and args.verification_command == "record":
1164
+ result = record_verification(
1165
+ paths,
1166
+ workflow_run_id=args.run,
1167
+ result=args.result,
1168
+ reasons=args.reason,
1169
+ verifier_role=args.verifier_role,
1170
+ rubric_json=args.rubric_json,
1171
+ target_job_id=args.target_job,
1172
+ )
1173
+ if json_output:
1174
+ _print_json(result)
1175
+ else:
1176
+ print(result["id"])
1177
+ return 0
1178
+
1179
+ if args.command == "decision" and args.decision_command == "open":
1180
+ result = open_decision(
1181
+ paths,
1182
+ question=args.question,
1183
+ recommendation=args.recommendation,
1184
+ blocks_json=args.blocks_json,
1185
+ escalation_id=args.escalation,
1186
+ )
1187
+ if json_output:
1188
+ _print_json(result)
1189
+ else:
1190
+ print(result["id"])
1191
+ return 0
1192
+
1193
+ if args.command == "decision" and args.decision_command == "resolve":
1194
+ result = resolve_decision(
1195
+ paths,
1196
+ decision_id=args.decision_id,
1197
+ selected_option=args.selected_option,
1198
+ reason=args.reason,
1199
+ )
1200
+ if json_output:
1201
+ _print_json(result)
1202
+ else:
1203
+ print(f"Resolved decision {result['id']}")
1204
+ return 0
1205
+
1206
+ if args.command == "decision" and args.decision_command == "waive":
1207
+ result = waive_decision(paths, decision_id=args.decision_id, reason=args.reason)
1208
+ if json_output:
1209
+ _print_json(result)
1210
+ else:
1211
+ print(f"Waived decision {result['id']}")
1212
+ return 0
1213
+
1214
+ if args.command == "decision" and args.decision_command == "list":
1215
+ decisions = list_decisions(paths, status=args.status)
1216
+ if json_output:
1217
+ _print_json({"ok": True, "decisions": decisions})
1218
+ elif decisions:
1219
+ for decision in decisions:
1220
+ print(f"{decision['id']} {decision['status']} question={decision['question']}")
1221
+ else:
1222
+ print("No decisions")
1223
+ return 0
1224
+
1225
+ if args.command == "decision" and args.decision_command == "read":
1226
+ decision = read_decision(paths, args.decision_id)
1227
+ if json_output:
1228
+ _print_json({"ok": True, "decision": decision})
1229
+ else:
1230
+ print(to_pretty_json(decision))
1231
+ return 0
1232
+
1233
+ if args.command == "escalation" and args.escalation_command == "open":
1234
+ result = open_escalation(
1235
+ paths,
1236
+ severity=args.severity,
1237
+ question=args.question,
1238
+ recommendation=args.recommendation,
1239
+ workflow_run_id=args.run,
1240
+ )
1241
+ if json_output:
1242
+ _print_json(result)
1243
+ else:
1244
+ print(result["id"])
1245
+ return 0
1246
+
1247
+ if args.command == "escalation" and args.escalation_command == "resolve":
1248
+ result = resolve_escalation(
1249
+ paths,
1250
+ escalation_id=args.escalation_id,
1251
+ summary=args.summary,
1252
+ decision_id=args.decision,
1253
+ )
1254
+ if json_output:
1255
+ _print_json(result)
1256
+ else:
1257
+ print(f"Resolved escalation {result['id']}")
1258
+ return 0
1259
+
1260
+ if args.command == "escalation" and args.escalation_command == "cancel":
1261
+ result = cancel_escalation(paths, escalation_id=args.escalation_id, summary=args.summary)
1262
+ if json_output:
1263
+ _print_json(result)
1264
+ else:
1265
+ print(f"Cancelled escalation {result['id']}")
1266
+ return 0
1267
+
1268
+ if args.command == "escalation" and args.escalation_command == "list":
1269
+ escalations = list_escalations(paths, status=args.status)
1270
+ if json_output:
1271
+ _print_json({"ok": True, "escalations": escalations})
1272
+ elif escalations:
1273
+ for escalation in escalations:
1274
+ print(
1275
+ f"{escalation['id']} {escalation['status']} severity={escalation['severity']} "
1276
+ f"run={escalation['workflow_run_id'] or ''}"
1277
+ )
1278
+ else:
1279
+ print("No escalations")
1280
+ return 0
1281
+
1282
+ if args.command == "escalation" and args.escalation_command == "read":
1283
+ escalation = read_escalation(paths, args.escalation_id)
1284
+ if json_output:
1285
+ _print_json({"ok": True, "escalation": escalation})
1286
+ else:
1287
+ print(to_pretty_json(escalation))
1288
+ return 0
1289
+
1290
+ if args.command == "checkpoint" and args.checkpoint_command == "status":
1291
+ status = checkpoint_status(paths)
1292
+ if json_output:
1293
+ _print_json(status)
1294
+ else:
1295
+ print(to_pretty_json(status))
1296
+ return 0
1297
+
1298
+ if args.command == "checkpoint" and args.checkpoint_command == "record":
1299
+ result = record_checkpoint(
1300
+ paths,
1301
+ summary=args.summary,
1302
+ evidence=args.evidence,
1303
+ review_type=args.review_type,
1304
+ )
1305
+ if json_output:
1306
+ _print_json(result)
1307
+ else:
1308
+ print(f"Recorded checkpoint {result['checkpoint_id']}")
1309
+ return 0
1310
+
1311
+ if args.command == "next":
1312
+ if args.strict:
1313
+ validation = validate_project(paths, strict=True)
1314
+ if not validation.ok:
1315
+ action = build_next_action(
1316
+ action_type="resolve_validation_errors",
1317
+ command="pcl report validation --strict",
1318
+ reason="Strict validation failed; review diagnostics before continuing the loop.",
1319
+ target={
1320
+ "strict": True,
1321
+ "ok": validation.ok,
1322
+ "errors": validation.errors,
1323
+ "warnings": validation.warnings,
1324
+ },
1325
+ priority=1,
1326
+ blocking=True,
1327
+ requires_human=True,
1328
+ safe_to_run=True,
1329
+ expected_after="Strict validation passes and normal next-action routing can resume.",
1330
+ )
1331
+ else:
1332
+ action = next_action(paths)
1333
+ else:
1334
+ action = next_action(paths)
1335
+ if json_output:
1336
+ _print_json(action)
1337
+ elif args.explain:
1338
+ print(_format_next_explanation(action))
1339
+ else:
1340
+ print(to_pretty_json(action))
1341
+ return 0
1342
+
1343
+ if args.command == "export" and args.export_command == "csv":
1344
+ paths_written = export_csv(paths)
1345
+ if json_output:
1346
+ _print_json({"ok": True, "paths": [str(p) for p in paths_written]})
1347
+ else:
1348
+ for p in paths_written:
1349
+ print(p)
1350
+ return 0
1351
+
1352
+ if args.command == "report" and args.report_command == "goal":
1353
+ result = report_goal(paths, args.goal_id)
1354
+ if json_output:
1355
+ _print_json(result)
1356
+ else:
1357
+ print(result["path"])
1358
+ return 0
1359
+
1360
+ if args.command == "report" and args.report_command == "run":
1361
+ result = report_run(paths, args.workflow_run_id)
1362
+ if json_output:
1363
+ _print_json(result)
1364
+ else:
1365
+ print(result["path"])
1366
+ return 0
1367
+
1368
+ if args.command == "report" and args.report_command == "feature":
1369
+ result = report_feature(paths, args.feature_id)
1370
+ if json_output:
1371
+ _print_json(result)
1372
+ else:
1373
+ print(result["path"])
1374
+ return 0
1375
+
1376
+ if args.command == "report" and args.report_command == "defect":
1377
+ result = report_defect(paths, args.defect_id)
1378
+ if json_output:
1379
+ _print_json(result)
1380
+ else:
1381
+ print(result["path"])
1382
+ return 0
1383
+
1384
+ if args.command == "report" and args.report_command == "validation":
1385
+ result = report_validation(paths, strict=args.strict)
1386
+ if json_output:
1387
+ _print_json(result)
1388
+ else:
1389
+ print(result["path"])
1390
+ return 0
1391
+
1392
+ parser.error("Unhandled command")
1393
+ return 2
1394
+ except PclError as exc:
1395
+ _print_error(exc, json_output=json_output)
1396
+ return exc.exit_code
1397
+ except sqlite3.Error as exc:
1398
+ error = DataStoreError(f"SQLite error while running {args.command}: {exc}")
1399
+ _print_error(error, json_output=json_output)
1400
+ return error.exit_code
1401
+
1402
+
1403
+ if __name__ == "__main__":
1404
+ raise SystemExit(main())