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/commands.py ADDED
@@ -0,0 +1,1006 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from json import JSONDecodeError
5
+
6
+ from .checkpoints import checkpoint_status
7
+ from .db import connect
8
+ from .evidence import record_inline_evidence
9
+ from .events import append_event
10
+ from .errors import InvalidInputError
11
+ from .guards import require_initialized
12
+ from .ids import next_prefixed_id
13
+ from .lifecycle import ACTIVE_JOB_STATUSES, ACTIVE_RUN_STATUSES, TERMINAL_JOB_STATUSES
14
+ from .links import linked_decisions_for_escalation
15
+ from .paths import ProjectPaths
16
+ from .timeutil import utc_now_iso
17
+ from .workflow_proposals import next_reviewable_workflow_proposal
18
+
19
+
20
+ FEATURE_STATUSES = {"discovered", "specified", "needs_test", "needs_fix", "passing", "done", "waived"}
21
+
22
+
23
+ def _normalized_json_object(raw: str, field_name: str) -> str:
24
+ try:
25
+ value = json.loads(raw)
26
+ except JSONDecodeError as exc:
27
+ raise InvalidInputError(
28
+ f"{field_name} must be valid JSON: {exc.msg}.",
29
+ details={"field": field_name, "position": exc.pos},
30
+ ) from exc
31
+ if not isinstance(value, dict):
32
+ raise InvalidInputError(
33
+ f"{field_name} must be a JSON object.",
34
+ details={"field": field_name, "type": type(value).__name__},
35
+ )
36
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
37
+
38
+
39
+ def create_goal(paths: ProjectPaths, *, title: str, completion_json: str = "{}", budget_json: str = "{}") -> str:
40
+ require_initialized(paths)
41
+ completion_json = _normalized_json_object(completion_json, "completion-json")
42
+ budget_json = _normalized_json_object(budget_json, "budget-json")
43
+
44
+ conn = connect(paths.db_path)
45
+ try:
46
+ goal_id = next_prefixed_id(conn, "goals", "G")
47
+ now = utc_now_iso()
48
+ conn.execute(
49
+ """
50
+ INSERT INTO goals(id, title, status, completion_json, stop_conditions_json, budget_json, created_at, updated_at)
51
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
52
+ """,
53
+ (goal_id, title, "open", completion_json, "{}", budget_json, now, now),
54
+ )
55
+ append_event(
56
+ conn=conn,
57
+ events_path=paths.events_path,
58
+ event_type="goal_created",
59
+ entity_type="goal",
60
+ entity_id=goal_id,
61
+ payload={"title": title},
62
+ )
63
+ conn.commit()
64
+ return goal_id
65
+ finally:
66
+ conn.close()
67
+
68
+
69
+ def add_feature(paths: ProjectPaths, *, name: str, surface: str, description: str = "", evidence: str = "") -> str:
70
+ require_initialized(paths)
71
+
72
+ conn = connect(paths.db_path)
73
+ try:
74
+ feature_id = next_prefixed_id(conn, "features", "F")
75
+ now = utc_now_iso()
76
+ conn.execute(
77
+ """
78
+ INSERT INTO features(id, name, surface, description, status, confidence, created_at, updated_at)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
80
+ """,
81
+ (feature_id, name, surface, description, "discovered", "medium", now, now),
82
+ )
83
+ payload = {"name": name, "surface": surface, "description": description, "evidence": evidence}
84
+ append_event(
85
+ conn=conn,
86
+ events_path=paths.events_path,
87
+ event_type="feature_added",
88
+ entity_type="feature",
89
+ entity_id=feature_id,
90
+ payload=payload,
91
+ )
92
+ conn.commit()
93
+ return feature_id
94
+ finally:
95
+ conn.close()
96
+
97
+
98
+ def list_features(paths: ProjectPaths, *, status: str | None = None) -> list[dict]:
99
+ require_initialized(paths)
100
+ if status:
101
+ _require_feature_status(status)
102
+
103
+ clauses: list[str] = []
104
+ params: list[str] = []
105
+ if status:
106
+ clauses.append("status = ?")
107
+ params.append(status)
108
+ where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
109
+
110
+ conn = connect(paths.db_path)
111
+ try:
112
+ rows = conn.execute(
113
+ f"""
114
+ SELECT id, name, surface, description, status, confidence, created_at, updated_at
115
+ FROM features
116
+ {where_sql}
117
+ ORDER BY id
118
+ """,
119
+ tuple(params),
120
+ ).fetchall()
121
+ return [dict(row) for row in rows]
122
+ finally:
123
+ conn.close()
124
+
125
+
126
+ def read_feature(paths: ProjectPaths, feature_id: str) -> dict:
127
+ require_initialized(paths)
128
+ _validate_identifier(feature_id, "feature_id")
129
+
130
+ conn = connect(paths.db_path)
131
+ try:
132
+ row = conn.execute(
133
+ """
134
+ SELECT id, name, surface, description, status, confidence, created_at, updated_at
135
+ FROM features
136
+ WHERE id = ?
137
+ """,
138
+ (feature_id,),
139
+ ).fetchone()
140
+ if row is None:
141
+ raise InvalidInputError(
142
+ f"Feature does not exist: {feature_id}",
143
+ details={"feature_id": feature_id},
144
+ )
145
+ return dict(row)
146
+ finally:
147
+ conn.close()
148
+
149
+
150
+ def set_feature_status(
151
+ paths: ProjectPaths,
152
+ feature_id: str,
153
+ *,
154
+ status: str,
155
+ summary: str,
156
+ evidence: str,
157
+ ) -> dict:
158
+ require_initialized(paths)
159
+ _validate_identifier(feature_id, "feature_id")
160
+ _require_feature_status(status)
161
+ _require_text(summary, "--summary is required to update feature status.")
162
+ _require_text(evidence, "--evidence is required to update feature status.")
163
+
164
+ conn = connect(paths.db_path)
165
+ try:
166
+ feature = conn.execute(
167
+ "SELECT id, status FROM features WHERE id = ?",
168
+ (feature_id,),
169
+ ).fetchone()
170
+ if feature is None:
171
+ raise InvalidInputError(
172
+ f"Feature does not exist: {feature_id}",
173
+ details={"feature_id": feature_id},
174
+ )
175
+ previous_status = str(feature["status"])
176
+ if previous_status == status:
177
+ raise InvalidInputError(
178
+ f"Feature {feature_id} is already {status}.",
179
+ details={"feature_id": feature_id, "status": status},
180
+ )
181
+
182
+ now = utc_now_iso()
183
+ evidence_id = record_inline_evidence(
184
+ conn,
185
+ evidence_type="feature_status",
186
+ summary=evidence.strip(),
187
+ context=f"feature/{feature_id}/status",
188
+ command="pcl feature status",
189
+ )
190
+ conn.execute(
191
+ "UPDATE features SET status = ?, updated_at = ? WHERE id = ?",
192
+ (status, now, feature_id),
193
+ )
194
+ append_event(
195
+ conn=conn,
196
+ events_path=paths.events_path,
197
+ event_type="feature_status_updated",
198
+ entity_type="feature",
199
+ entity_id=feature_id,
200
+ payload={
201
+ "previous_status": previous_status,
202
+ "status": status,
203
+ "summary": summary.strip(),
204
+ "evidence": evidence.strip(),
205
+ "evidence_id": evidence_id,
206
+ "source": "manual",
207
+ },
208
+ )
209
+ conn.commit()
210
+ return {
211
+ "ok": True,
212
+ "feature_id": feature_id,
213
+ "previous_status": previous_status,
214
+ "status": status,
215
+ "summary": summary.strip(),
216
+ "evidence_id": evidence_id,
217
+ }
218
+ finally:
219
+ conn.close()
220
+
221
+
222
+ def open_defect(
223
+ paths: ProjectPaths,
224
+ *,
225
+ feature_id: str,
226
+ severity: str,
227
+ expected: str,
228
+ actual: str,
229
+ test_case_id: str | None = None,
230
+ reproduction: str = "",
231
+ evidence: str = "",
232
+ ) -> str:
233
+ require_initialized(paths)
234
+
235
+ conn = connect(paths.db_path)
236
+ try:
237
+ row = conn.execute("SELECT id FROM features WHERE id = ?", (feature_id,)).fetchone()
238
+ if row is None:
239
+ raise InvalidInputError(
240
+ f"Feature does not exist: {feature_id}",
241
+ details={"feature_id": feature_id},
242
+ )
243
+ defect_id = next_prefixed_id(conn, "defects", "D")
244
+ now = utc_now_iso()
245
+ evidence_id = None
246
+ if evidence.strip():
247
+ evidence_id = record_inline_evidence(
248
+ conn,
249
+ evidence_type="defect_open",
250
+ summary=evidence.strip(),
251
+ context=f"defect/{defect_id}/open",
252
+ command="pcl defect open",
253
+ )
254
+ conn.execute(
255
+ """
256
+ INSERT INTO defects(id, feature_id, test_case_id, severity, expected, actual, reproduction, status, evidence_id, created_at, updated_at)
257
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
258
+ """,
259
+ (
260
+ defect_id,
261
+ feature_id,
262
+ test_case_id or None,
263
+ severity,
264
+ expected,
265
+ actual,
266
+ reproduction,
267
+ "open",
268
+ evidence_id,
269
+ now,
270
+ now,
271
+ ),
272
+ )
273
+ conn.execute("UPDATE features SET status = ?, updated_at = ? WHERE id = ?", ("needs_fix", now, feature_id))
274
+ append_event(
275
+ conn=conn,
276
+ events_path=paths.events_path,
277
+ event_type="defect_opened",
278
+ entity_type="defect",
279
+ entity_id=defect_id,
280
+ payload={
281
+ "feature_id": feature_id,
282
+ "severity": severity,
283
+ "expected": expected,
284
+ "actual": actual,
285
+ "test_case_id": test_case_id,
286
+ "evidence": evidence,
287
+ "evidence_id": evidence_id,
288
+ },
289
+ )
290
+ conn.commit()
291
+ return defect_id
292
+ finally:
293
+ conn.close()
294
+
295
+
296
+ def loop_status(paths: ProjectPaths) -> dict:
297
+ require_initialized(paths)
298
+
299
+ conn = connect(paths.db_path)
300
+ try:
301
+ open_goals = conn.execute(
302
+ """
303
+ SELECT id, title, status
304
+ FROM goals
305
+ WHERE status NOT IN ('closed', 'cancelled')
306
+ ORDER BY created_at DESC
307
+ """
308
+ ).fetchall()
309
+ open_defects = conn.execute(
310
+ """
311
+ SELECT id, feature_id, severity, status
312
+ FROM defects
313
+ WHERE status NOT IN ('closed', 'waived')
314
+ ORDER BY created_at DESC
315
+ """
316
+ ).fetchall()
317
+ runs = conn.execute("SELECT id, workflow_id, status, iteration FROM workflow_runs ORDER BY started_at DESC LIMIT 10").fetchall()
318
+ return {
319
+ "open_goals": [dict(r) for r in open_goals],
320
+ "open_defects": [dict(r) for r in open_defects],
321
+ "recent_workflow_runs": [dict(r) for r in runs],
322
+ }
323
+ finally:
324
+ conn.close()
325
+
326
+
327
+ def build_next_action(
328
+ *,
329
+ action_type: str,
330
+ command: str,
331
+ reason: str,
332
+ target,
333
+ priority: int,
334
+ blocking: bool,
335
+ requires_human: bool,
336
+ safe_to_run: bool,
337
+ expected_after: str,
338
+ ) -> dict:
339
+ run_policy = _run_policy(
340
+ blocking=blocking,
341
+ requires_human=requires_human,
342
+ safe_to_run=safe_to_run,
343
+ )
344
+ return {
345
+ "type": action_type,
346
+ "command": command,
347
+ "reason": reason,
348
+ "target": target,
349
+ "priority": priority,
350
+ "blocking": blocking,
351
+ "requires_human": requires_human,
352
+ "safe_to_run": safe_to_run,
353
+ "expected_after": expected_after,
354
+ "run_policy": run_policy,
355
+ "human_guidance": _human_guidance(
356
+ run_policy=run_policy,
357
+ blocking=blocking,
358
+ ),
359
+ }
360
+
361
+
362
+ def _run_policy(*, blocking: bool, requires_human: bool, safe_to_run: bool) -> str:
363
+ if safe_to_run:
364
+ return "agent_safe"
365
+ if requires_human:
366
+ return "human_decision"
367
+ if blocking:
368
+ return "manual_resolution"
369
+ return "manual_state_transition"
370
+
371
+
372
+ def _human_guidance(*, run_policy: str, blocking: bool) -> str:
373
+ prefix = "Normal loop continuation should wait. " if blocking else ""
374
+ if run_policy == "agent_safe":
375
+ return prefix + "An agent or automation may run this command in the current project context."
376
+ if run_policy == "human_decision":
377
+ return prefix + "A human should choose or confirm this state transition before the command is run."
378
+ if run_policy == "manual_resolution":
379
+ return prefix + "Resolve the blocking state deliberately; do not auto-run this command blindly."
380
+ return prefix + "This mutates durable loop state; run it deliberately after reviewing the recommendation."
381
+
382
+
383
+ def next_action(paths: ProjectPaths) -> dict:
384
+ status = loop_status(paths)
385
+ escalation = _open_escalation_next_action(paths)
386
+ if escalation is not None:
387
+ return escalation
388
+ decision = _open_decision_next_action(paths)
389
+ if decision is not None:
390
+ return decision
391
+ needs_human = _needs_human_escalation_next_action(paths)
392
+ if needs_human is not None:
393
+ return needs_human
394
+ unfinished_executor = _unfinished_executor_next_action(paths)
395
+ if unfinished_executor is not None:
396
+ return unfinished_executor
397
+ active = _active_workflow_next_action(paths)
398
+ if active is not None:
399
+ return active
400
+ retry_executor = _failed_executor_retry_next_action(paths)
401
+ if retry_executor is not None:
402
+ return retry_executor
403
+ if status["open_defects"]:
404
+ defect = status["open_defects"][0]
405
+ return _defect_next_action(defect)
406
+ proposal = _workflow_proposal_review_next_action(paths)
407
+ if proposal is not None:
408
+ return proposal
409
+ checkpoint = _checkpoint_review_next_action(paths)
410
+ if checkpoint is not None:
411
+ return checkpoint
412
+ if status["open_goals"]:
413
+ goal = status["open_goals"][0]
414
+ return build_next_action(
415
+ action_type="continue_goal",
416
+ command=f"pcl loop run feature_coverage --goal {goal['id']}",
417
+ reason="There is an open goal and no open defects.",
418
+ target=goal,
419
+ priority=60,
420
+ blocking=False,
421
+ requires_human=False,
422
+ safe_to_run=False,
423
+ expected_after="A workflow run exists for the open goal.",
424
+ )
425
+ uncovered_feature = _uncovered_feature_next_action(paths)
426
+ if uncovered_feature is not None:
427
+ return uncovered_feature
428
+ return build_next_action(
429
+ action_type="create_goal",
430
+ command="pcl goal create --title 'Reach feature coverage'",
431
+ reason="No open goal exists.",
432
+ target=None,
433
+ priority=70,
434
+ blocking=False,
435
+ requires_human=True,
436
+ safe_to_run=False,
437
+ expected_after="An open goal exists and `pcl next` can route work from it.",
438
+ )
439
+
440
+
441
+ def _uncovered_feature_next_action(paths: ProjectPaths) -> dict | None:
442
+ conn = connect(paths.db_path)
443
+ try:
444
+ row = conn.execute(
445
+ """
446
+ SELECT id, name, surface, description, status, confidence, created_at, updated_at
447
+ FROM features
448
+ WHERE status IN ('discovered', 'specified', 'needs_test', 'needs_fix')
449
+ ORDER BY created_at, id
450
+ LIMIT 1
451
+ """
452
+ ).fetchone()
453
+ if row is None:
454
+ return None
455
+ feature = dict(row)
456
+ feature_id = str(feature["id"])
457
+ return build_next_action(
458
+ action_type="cover_feature",
459
+ command=f"pcl goal create --title 'Cover feature {feature_id}'",
460
+ reason="No open goal exists, and a tracked feature still needs coverage work.",
461
+ target=feature,
462
+ priority=65,
463
+ blocking=False,
464
+ requires_human=True,
465
+ safe_to_run=False,
466
+ expected_after="An open goal exists for the uncovered feature and can run feature coverage.",
467
+ )
468
+ finally:
469
+ conn.close()
470
+
471
+
472
+ def _open_escalation_next_action(paths: ProjectPaths) -> dict | None:
473
+ conn = connect(paths.db_path)
474
+ try:
475
+ row = conn.execute(
476
+ """
477
+ SELECT id, workflow_run_id, severity, question, recommendation, status, created_at
478
+ FROM escalations
479
+ WHERE status = 'open'
480
+ ORDER BY created_at DESC, id DESC
481
+ LIMIT 1
482
+ """
483
+ ).fetchone()
484
+ if row is None:
485
+ return None
486
+ escalation = dict(row)
487
+ decisions = [
488
+ dict(decision)
489
+ for decision in conn.execute(
490
+ """
491
+ SELECT id, status, blocks_json, created_at
492
+ FROM decisions
493
+ ORDER BY id
494
+ """
495
+ ).fetchall()
496
+ ]
497
+ linked_decisions = linked_decisions_for_escalation(decisions, str(escalation["id"]))
498
+ linked_decision_ids = [str(decision["id"]) for decision in linked_decisions]
499
+ escalation["linked_decision_ids"] = linked_decision_ids
500
+ if linked_decision_ids:
501
+ decision_id = linked_decision_ids[0]
502
+ command = (
503
+ f"pcl escalation resolve {escalation['id']} --decision {decision_id} "
504
+ "--summary 'Record the outcome'"
505
+ )
506
+ reason = "A human escalation is open and has a linked decision to record in the resolution."
507
+ else:
508
+ command = (
509
+ f"pcl decision open --escalation {escalation['id']} "
510
+ f"--question 'Record the human decision for {escalation['id']}' "
511
+ "--recommendation 'Choose the safe next step'"
512
+ )
513
+ reason = "A human escalation is open and needs a linked durable decision before resolution."
514
+ return build_next_action(
515
+ action_type="resolve_escalation",
516
+ command=command,
517
+ reason=reason,
518
+ target=escalation,
519
+ priority=10,
520
+ blocking=True,
521
+ requires_human=True,
522
+ safe_to_run=False,
523
+ expected_after=(
524
+ "The escalation is resolved with the linked decision."
525
+ if linked_decision_ids
526
+ else "A linked decision exists for the escalation."
527
+ ),
528
+ )
529
+ finally:
530
+ conn.close()
531
+
532
+
533
+ def _open_decision_next_action(paths: ProjectPaths) -> dict | None:
534
+ conn = connect(paths.db_path)
535
+ try:
536
+ row = conn.execute(
537
+ """
538
+ SELECT id, status, question, recommendation, blocks_json, created_at
539
+ FROM decisions
540
+ WHERE status = 'open'
541
+ ORDER BY created_at DESC, id DESC
542
+ LIMIT 1
543
+ """
544
+ ).fetchone()
545
+ if row is None:
546
+ return None
547
+ decision = dict(row)
548
+ return build_next_action(
549
+ action_type="resolve_decision",
550
+ command=(
551
+ f"pcl decision resolve {decision['id']} "
552
+ "--selected-option 'Record the selected option' --reason 'Explain the human decision'"
553
+ ),
554
+ reason="A human decision is open and blocks safe continuation.",
555
+ target=decision,
556
+ priority=20,
557
+ blocking=True,
558
+ requires_human=True,
559
+ safe_to_run=False,
560
+ expected_after="The decision is resolved or waived.",
561
+ )
562
+ finally:
563
+ conn.close()
564
+
565
+
566
+ def _needs_human_escalation_next_action(paths: ProjectPaths) -> dict | None:
567
+ conn = connect(paths.db_path)
568
+ try:
569
+ placeholders = ", ".join("?" for _ in ACTIVE_RUN_STATUSES)
570
+ runs = conn.execute(
571
+ f"""
572
+ SELECT id, workflow_id, goal_id, status
573
+ FROM workflow_runs
574
+ WHERE status IN ({placeholders})
575
+ ORDER BY started_at DESC, id DESC
576
+ """,
577
+ tuple(sorted(ACTIVE_RUN_STATUSES)),
578
+ ).fetchall()
579
+ for run in runs:
580
+ verification = conn.execute(
581
+ """
582
+ SELECT
583
+ verifications.id,
584
+ verifications.result,
585
+ verifications.reasons_json,
586
+ verifications.created_at,
587
+ events.rowid AS event_rowid
588
+ FROM verifications
589
+ LEFT JOIN events
590
+ ON events.entity_type = 'verification'
591
+ AND events.entity_id = verifications.id
592
+ AND events.event_type = 'verification_recorded'
593
+ WHERE workflow_run_id = ?
594
+ ORDER BY verifications.created_at DESC, verifications.id DESC
595
+ LIMIT 1
596
+ """,
597
+ (run["id"],),
598
+ ).fetchone()
599
+ if verification is None or verification["result"] != "needs_human":
600
+ continue
601
+ escalations = conn.execute(
602
+ """
603
+ SELECT
604
+ escalations.id,
605
+ escalations.status,
606
+ escalations.created_at,
607
+ events.rowid AS event_rowid
608
+ FROM escalations
609
+ LEFT JOIN events
610
+ ON events.entity_type = 'escalation'
611
+ AND events.entity_id = escalations.id
612
+ AND events.event_type = 'escalation_opened'
613
+ WHERE escalations.workflow_run_id = ?
614
+ ORDER BY escalations.created_at DESC, escalations.id DESC
615
+ """,
616
+ (run["id"],),
617
+ ).fetchall()
618
+ if any(_escalation_opened_at_or_after(escalation, verification) for escalation in escalations):
619
+ continue
620
+ target = dict(run)
621
+ target["verification_id"] = verification["id"]
622
+ target["verification_result"] = verification["result"]
623
+ target["reasons_json"] = verification["reasons_json"]
624
+ target["verification_created_at"] = verification["created_at"]
625
+ return build_next_action(
626
+ action_type="open_escalation",
627
+ command=(
628
+ f"pcl escalation open --run {run['id']} --severity high "
629
+ "--question 'What human decision is needed?' "
630
+ "--recommendation 'Review the needs_human verification and choose the next step'"
631
+ ),
632
+ reason="The latest verification needs human input and no open escalation exists for this run.",
633
+ target=target,
634
+ priority=30,
635
+ blocking=True,
636
+ requires_human=True,
637
+ safe_to_run=False,
638
+ expected_after="An open escalation records the human-required ambiguity for this run.",
639
+ )
640
+ return None
641
+ finally:
642
+ conn.close()
643
+
644
+
645
+ def _escalation_opened_at_or_after(escalation, verification) -> bool:
646
+ escalation_event_rowid = escalation["event_rowid"]
647
+ verification_event_rowid = verification["event_rowid"]
648
+ if escalation_event_rowid is not None and verification_event_rowid is not None:
649
+ return int(escalation_event_rowid) >= int(verification_event_rowid)
650
+ return str(escalation["created_at"]) >= str(verification["created_at"])
651
+
652
+
653
+ def _defect_next_action(defect: dict) -> dict:
654
+ defect_id = defect["id"]
655
+ defect_status = defect.get("status")
656
+ commands = {
657
+ "open": (
658
+ "triage_defect",
659
+ f"pcl defect triage {defect_id} --summary 'Summarize impact and priority'",
660
+ "A defect is open and needs triage before repair starts.",
661
+ ),
662
+ "triaged": (
663
+ "start_defect",
664
+ f"pcl defect start {defect_id} --summary 'Begin repair work'",
665
+ "A triaged defect is ready to start repair.",
666
+ ),
667
+ "in_progress": (
668
+ "fix_defect",
669
+ f"pcl defect fix {defect_id} --summary 'Summarize the fix' --evidence 'Test or commit evidence'",
670
+ "A defect is in progress and needs fix evidence.",
671
+ ),
672
+ "fixed": (
673
+ "verify_defect",
674
+ f"pcl defect verify {defect_id} --summary 'Summarize verification' --verification V-0001",
675
+ "A fixed defect needs an approved verification linked to its repair workflow.",
676
+ ),
677
+ "verified": (
678
+ "close_defect",
679
+ f"pcl defect close {defect_id} --summary 'Close verified defect' --evidence 'Verification evidence'",
680
+ "A verified defect can be closed with evidence.",
681
+ ),
682
+ }
683
+ action_type, command, reason = commands.get(
684
+ str(defect_status),
685
+ (
686
+ "repair_defect",
687
+ f"pcl loop run defect_repair --defect {defect_id}",
688
+ "There is at least one active defect.",
689
+ ),
690
+ )
691
+ return build_next_action(
692
+ action_type=action_type,
693
+ command=command,
694
+ reason=reason,
695
+ target=defect,
696
+ priority=50,
697
+ blocking=False,
698
+ requires_human=False,
699
+ safe_to_run=False,
700
+ expected_after=f"Defect {defect_id} advances beyond {defect_status}.",
701
+ )
702
+
703
+
704
+ def _workflow_proposal_review_next_action(paths: ProjectPaths) -> dict | None:
705
+ proposal = next_reviewable_workflow_proposal(paths)
706
+ if proposal is None:
707
+ return None
708
+ proposal_id = str(proposal["id"])
709
+ return build_next_action(
710
+ action_type="review_workflow_proposal",
711
+ command=f"pcl workflow proposals approve {proposal_id} --summary 'Approve this workflow template'",
712
+ reason="A workflow proposal is waiting for human review before it can become executable.",
713
+ target=proposal,
714
+ priority=55,
715
+ blocking=False,
716
+ requires_human=True,
717
+ safe_to_run=False,
718
+ expected_after="The proposal is approved into `.project-loop/workflows/` or cancelled.",
719
+ )
720
+
721
+
722
+ def _checkpoint_review_next_action(paths: ProjectPaths) -> dict | None:
723
+ status = checkpoint_status(paths)
724
+ if not status["checkpoint_recommended"]:
725
+ return None
726
+ completed = status["completed_features_since_checkpoint"]
727
+ threshold = status["threshold"]
728
+ return build_next_action(
729
+ action_type="checkpoint_review",
730
+ command=(
731
+ "pcl checkpoint record --review-type integration "
732
+ "--summary 'Review commit/package checkpoint, UX checklist, and next big-goal priority' "
733
+ "--evidence 'Reviewed code state, validation results, UX checklist, and next feature priority'"
734
+ ),
735
+ reason=(
736
+ f"{completed} features were marked done since the last checkpoint; "
737
+ f"the checkpoint threshold is {threshold}. Pause before another feature coverage run "
738
+ "and review the larger product goal."
739
+ ),
740
+ target=status,
741
+ priority=58,
742
+ blocking=False,
743
+ requires_human=True,
744
+ safe_to_run=False,
745
+ expected_after=(
746
+ "A checkpoint_review evidence record exists, and `pcl next` can resume normal goal routing."
747
+ ),
748
+ )
749
+
750
+
751
+ def _unfinished_executor_next_action(paths: ProjectPaths) -> dict | None:
752
+ conn = connect(paths.db_path)
753
+ try:
754
+ placeholders = ", ".join("?" for _ in ACTIVE_RUN_STATUSES)
755
+ runs = conn.execute(
756
+ f"""
757
+ SELECT id, workflow_id, goal_id, status, iteration, started_at
758
+ FROM workflow_runs
759
+ WHERE status IN ({placeholders})
760
+ ORDER BY started_at DESC, id DESC
761
+ """,
762
+ tuple(sorted(ACTIVE_RUN_STATUSES)),
763
+ ).fetchall()
764
+ for run in runs:
765
+ latest_event = conn.execute(
766
+ """
767
+ SELECT event_type, rowid
768
+ FROM events
769
+ WHERE entity_type = 'workflow_run'
770
+ AND entity_id = ?
771
+ AND event_type IN (
772
+ 'workflow_execution_started',
773
+ 'workflow_execution_resumed',
774
+ 'workflow_execution_finished'
775
+ )
776
+ ORDER BY rowid DESC
777
+ LIMIT 1
778
+ """,
779
+ (run["id"],),
780
+ ).fetchone()
781
+ if latest_event is None or latest_event["event_type"] == "workflow_execution_finished":
782
+ continue
783
+ target = dict(run)
784
+ target["latest_executor_event"] = latest_event["event_type"]
785
+ target["latest_executor_event_rowid"] = latest_event["rowid"]
786
+ return build_next_action(
787
+ action_type="resume_workflow_execution",
788
+ command=f"pcl loop execute {run['workflow_id']} --resume {run['id']}",
789
+ reason="An executor-owned workflow run is active without a finished execution event.",
790
+ target=target,
791
+ priority=35,
792
+ blocking=True,
793
+ requires_human=False,
794
+ safe_to_run=False,
795
+ expected_after="The existing workflow run has workflow execution evidence and a terminal status or next verification step.",
796
+ )
797
+ return None
798
+ finally:
799
+ conn.close()
800
+
801
+
802
+ def _failed_executor_retry_next_action(paths: ProjectPaths) -> dict | None:
803
+ conn = connect(paths.db_path)
804
+ try:
805
+ retried_run_ids = _retried_workflow_run_ids(conn)
806
+ rows = conn.execute(
807
+ """
808
+ SELECT id, workflow_id, goal_id, status, iteration, started_at, ended_at, summary
809
+ FROM workflow_runs
810
+ WHERE status = 'failed'
811
+ ORDER BY ended_at DESC, started_at DESC, id DESC
812
+ """
813
+ ).fetchall()
814
+ for row in rows:
815
+ run_id = str(row["id"])
816
+ if run_id in retried_run_ids:
817
+ continue
818
+ finished = conn.execute(
819
+ """
820
+ SELECT payload_json, rowid
821
+ FROM events
822
+ WHERE entity_type = 'workflow_run'
823
+ AND entity_id = ?
824
+ AND event_type = 'workflow_execution_finished'
825
+ ORDER BY rowid DESC
826
+ LIMIT 1
827
+ """,
828
+ (run_id,),
829
+ ).fetchone()
830
+ if finished is None:
831
+ continue
832
+ payload = _parse_event_payload(str(finished["payload_json"] or "{}"))
833
+ if payload.get("status") != "failed":
834
+ continue
835
+ target = dict(row)
836
+ target["executor_event_rowid"] = finished["rowid"]
837
+ target["failure_reason"] = payload.get("failure_reason") or row["summary"]
838
+ target["evidence_id"] = payload.get("evidence_id") or ""
839
+ return build_next_action(
840
+ action_type="retry_workflow_execution",
841
+ command=f"pcl loop execute {row['workflow_id']} --retry {run_id}",
842
+ reason="The latest unretried executor workflow run failed and can be retried explicitly.",
843
+ target=target,
844
+ priority=45,
845
+ blocking=False,
846
+ requires_human=False,
847
+ safe_to_run=False,
848
+ expected_after="A new workflow run is linked to the failed run and records fresh execution evidence.",
849
+ )
850
+ return None
851
+ finally:
852
+ conn.close()
853
+
854
+
855
+ def _retried_workflow_run_ids(conn) -> set[str]:
856
+ rows = conn.execute(
857
+ """
858
+ SELECT payload_json
859
+ FROM events
860
+ WHERE event_type = 'workflow_execution_retried'
861
+ ORDER BY rowid
862
+ """
863
+ ).fetchall()
864
+ retried: set[str] = set()
865
+ for row in rows:
866
+ payload = _parse_event_payload(str(row["payload_json"] or "{}"))
867
+ retry_of = payload.get("retry_of_workflow_run_id")
868
+ if isinstance(retry_of, str) and retry_of:
869
+ retried.add(retry_of)
870
+ return retried
871
+
872
+
873
+ def _parse_event_payload(payload_json: str) -> dict:
874
+ try:
875
+ payload = json.loads(payload_json)
876
+ except JSONDecodeError:
877
+ return {}
878
+ return payload if isinstance(payload, dict) else {}
879
+
880
+
881
+ def _active_workflow_next_action(paths: ProjectPaths) -> dict | None:
882
+ conn = connect(paths.db_path)
883
+ try:
884
+ placeholders = ", ".join("?" for _ in ACTIVE_RUN_STATUSES)
885
+ run = conn.execute(
886
+ f"""
887
+ SELECT id, workflow_id, goal_id, status
888
+ FROM workflow_runs
889
+ WHERE status IN ({placeholders})
890
+ ORDER BY started_at DESC, id DESC
891
+ LIMIT 1
892
+ """,
893
+ tuple(sorted(ACTIVE_RUN_STATUSES)),
894
+ ).fetchone()
895
+ if run is None:
896
+ return None
897
+
898
+ job_placeholders = ", ".join("?" for _ in ACTIVE_JOB_STATUSES)
899
+ active_job = conn.execute(
900
+ f"""
901
+ SELECT id, role, status
902
+ FROM agent_jobs
903
+ WHERE workflow_run_id = ? AND status IN ({job_placeholders})
904
+ ORDER BY id
905
+ LIMIT 1
906
+ """,
907
+ (run["id"], *tuple(sorted(ACTIVE_JOB_STATUSES))),
908
+ ).fetchone()
909
+ target = dict(run)
910
+ if active_job is not None:
911
+ target["job"] = dict(active_job)
912
+ return build_next_action(
913
+ action_type="continue_workflow",
914
+ command=f"pcl jobs read {active_job['id']}",
915
+ reason="A workflow run is already active and has queued or running jobs.",
916
+ target=target,
917
+ priority=40,
918
+ blocking=False,
919
+ requires_human=False,
920
+ safe_to_run=True,
921
+ expected_after="The agent job prompt is reviewed and the job can be executed or completed.",
922
+ )
923
+
924
+ job_statuses = _job_status_counts(conn, str(run["id"]))
925
+ failed_or_cancelled = {
926
+ status: count
927
+ for status, count in job_statuses.items()
928
+ if status in TERMINAL_JOB_STATUSES and status != "passed" and count
929
+ }
930
+ target["job_statuses"] = job_statuses
931
+ if failed_or_cancelled:
932
+ return build_next_action(
933
+ action_type="resolve_workflow_failure",
934
+ command=f"pcl loop fail {run['id']} --summary 'Explain why this run failed'",
935
+ reason="The active workflow has failed or cancelled jobs.",
936
+ target=target,
937
+ priority=40,
938
+ blocking=True,
939
+ requires_human=False,
940
+ safe_to_run=False,
941
+ expected_after="The active workflow is marked failed or otherwise resolved.",
942
+ )
943
+
944
+ approved = conn.execute(
945
+ "SELECT id FROM verifications WHERE workflow_run_id = ? AND result = 'approved' ORDER BY created_at DESC LIMIT 1",
946
+ (run["id"],),
947
+ ).fetchone()
948
+ if approved is None:
949
+ return build_next_action(
950
+ action_type="record_verification",
951
+ command=f"pcl verification record --run {run['id']} --result approved --reason 'Summarize verification evidence'",
952
+ reason="All active workflow jobs are terminal, but no approved verification exists.",
953
+ target=target,
954
+ priority=40,
955
+ blocking=True,
956
+ requires_human=True,
957
+ safe_to_run=False,
958
+ expected_after="An approved, rejected, inconclusive, or needs_human verification exists for the run.",
959
+ )
960
+ target["verification_id"] = approved["id"]
961
+ return build_next_action(
962
+ action_type="complete_workflow",
963
+ command=f"pcl loop complete {run['id']} --summary 'Summarize completed workflow'",
964
+ reason="The active workflow has passed jobs and an approved verification.",
965
+ target=target,
966
+ priority=40,
967
+ blocking=False,
968
+ requires_human=False,
969
+ safe_to_run=False,
970
+ expected_after="The workflow run is marked passed.",
971
+ )
972
+ finally:
973
+ conn.close()
974
+
975
+
976
+ def _job_status_counts(conn, workflow_run_id: str) -> dict[str, int]:
977
+ rows = conn.execute(
978
+ "SELECT status, COUNT(*) AS count FROM agent_jobs WHERE workflow_run_id = ? GROUP BY status",
979
+ (workflow_run_id,),
980
+ ).fetchall()
981
+ return {str(row["status"]): int(row["count"]) for row in rows}
982
+
983
+
984
+ def to_pretty_json(value: object) -> str:
985
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
986
+
987
+
988
+ def _require_feature_status(status: str) -> None:
989
+ if status not in FEATURE_STATUSES:
990
+ raise InvalidInputError(
991
+ f"Invalid feature status: {status}",
992
+ details={"status": status, "allowed": sorted(FEATURE_STATUSES)},
993
+ )
994
+
995
+
996
+ def _require_text(value: str, message: str) -> None:
997
+ if not value.strip():
998
+ raise InvalidInputError(message)
999
+
1000
+
1001
+ def _validate_identifier(value: str, field_name: str) -> None:
1002
+ if not value or not all(c.isalnum() or c in {"_", "-"} for c in value):
1003
+ raise InvalidInputError(
1004
+ f"Invalid {field_name}: {value}",
1005
+ details={"field": field_name, "value": value},
1006
+ )