easy-coding-harness 1.1.0-beta.3 → 1.1.0-beta.4

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.
@@ -1,7 +1,414 @@
1
- from easy_coding_state import (
2
- build_machine_breadcrumbs,
3
- build_status_context,
4
- build_status_line,
1
+ """Lightweight workflow display shared by the CLI and prompt hooks."""
2
+
3
+ from pathlib import Path
4
+ from easy_coding_store import (
5
+ HELP_SUFFIX,
6
+ MANDATORY_DEV_SPEC_HEADERS,
7
+ READY_LINE,
8
+ TDD_INIT_TASK_TYPE,
9
+ TERMINAL_STATUSES,
10
+ WAITING_INIT_LINE,
11
+ agents_equivalent,
12
+ behavior_layers,
13
+ clear_session_pointer,
14
+ default_session,
15
+ display_path,
16
+ ensure_session,
17
+ execution_records,
18
+ is_automatic_transition,
19
+ load_json,
20
+ load_session,
21
+ load_task,
22
+ read_behavior_file,
23
+ resolve_behavior,
24
+ resolve_session_path,
25
+ tdd_readiness,
26
+ validate_transition,
27
+ write_session,
5
28
  )
6
29
 
7
- __all__ = ["build_machine_breadcrumbs", "build_status_context", "build_status_line"]
30
+
31
+ def pending_handoff_record(root: Path, task_id: str) -> dict | None:
32
+ latest = next((r for r in reversed(execution_records(root, task_id))
33
+ if r.get("type") in {"handoff", "claim"}), None)
34
+ return latest if latest and latest["type"] == "handoff" else None
35
+
36
+
37
+ def is_project_init_required(root: Path) -> bool:
38
+ project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
39
+ return bool(project_init and project_init.get("status") != "COMPLETE")
40
+
41
+
42
+ def get_pending_init_version(root: Path) -> str | None:
43
+ project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
44
+ if project_init and project_init.get("pending_init_since"):
45
+ return str(project_init["pending_init_since"])
46
+ return None
47
+
48
+
49
+ def spec_task_summary(task: dict | None) -> dict | None:
50
+ if not task or not isinstance(task.get("spec_source"), dict):
51
+ return None
52
+ dependencies = task.get("spec_dependency_evidence")
53
+ pending_dependencies = [
54
+ {
55
+ "source_task_id": record.get("source_task_id"),
56
+ "task_id": record.get("task_id"),
57
+ "dependency_type": record.get("dependency_type"),
58
+ "required_evidence": record.get("required_evidence"),
59
+ }
60
+ for record in dependencies or []
61
+ if isinstance(record, dict) and record.get("status") == "pending"
62
+ ]
63
+ return {
64
+ "source": task["spec_source"],
65
+ "selected_spec_tasks": task.get("selected_spec_tasks", []),
66
+ "repositories": task.get("spec_repositories", []),
67
+ "pending_dependencies": pending_dependencies,
68
+ "writeback": task.get("spec_writeback_progress"),
69
+ "context": task.get("spec_context"),
70
+ "pending_change": task.get("spec_change"),
71
+ }
72
+
73
+
74
+ def snapshot_state(
75
+ root: Path,
76
+ session_file: str | Path | None = None,
77
+ session: dict | None = None,
78
+ ) -> dict:
79
+ session_path = resolve_session_path(root, session_file)
80
+ resolved_session = session if session is not None else load_session(root, session_path)
81
+ if resolved_session is None:
82
+ resolved_session = default_session()
83
+
84
+ task_id = resolved_session.get("current_task")
85
+ task = load_task(root, str(task_id)) if task_id else None
86
+ missing = bool(task_id and task is None)
87
+ status = "idle"
88
+ if missing:
89
+ status = "MISSING"
90
+ elif task and task.get("status"):
91
+ status = str(task["status"])
92
+
93
+ if task_id and task and status in TERMINAL_STATUSES:
94
+ clear_session_pointer(resolved_session, task.get("last_agent"))
95
+ write_session(root, resolved_session, session_path)
96
+ task_id = None
97
+ task = None
98
+ missing = False
99
+ status = "idle"
100
+
101
+ (
102
+ project_approval_mode,
103
+ session_approval_mode,
104
+ effective_approval_mode,
105
+ project_workflow_mode,
106
+ session_workflow_mode,
107
+ configured_workflow_mode,
108
+ project_unit_test_mode,
109
+ session_unit_test_mode,
110
+ effective_unit_test_mode,
111
+ project_ut_coverage_threshold,
112
+ session_ut_coverage_threshold,
113
+ effective_ut_coverage_threshold,
114
+ ) = resolve_behavior(root, resolved_session)
115
+ concrete_workflow_mode = None
116
+ if task:
117
+ concrete_workflow_mode = task.get("workflow_mode")
118
+ proposal = task.get("workflow_mode_proposal")
119
+ if concrete_workflow_mode is None and isinstance(proposal, dict):
120
+ concrete_workflow_mode = proposal.get("selected_mode")
121
+ task_unit_test_mode = task.get("unit_test_mode") if task else None
122
+ task_ut_coverage_threshold = task.get("ut_coverage_threshold") if task else None
123
+ frozen_unit_test = bool(
124
+ task
125
+ and status not in {"ANALYSIS", "INIT"}
126
+ and task_unit_test_mode in {"none", "ut", "tdd"}
127
+ )
128
+ is_tdd_init = bool(
129
+ task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
130
+ )
131
+ displayed_unit_test_mode = (
132
+ "none" if is_tdd_init else task_unit_test_mode if frozen_unit_test else effective_unit_test_mode
133
+ )
134
+ displayed_ut_threshold = (
135
+ task_ut_coverage_threshold
136
+ if frozen_unit_test and isinstance(task_ut_coverage_threshold, int)
137
+ else effective_ut_coverage_threshold
138
+ )
139
+ should_check_readiness = bool(
140
+ effective_unit_test_mode in {"ut", "tdd"} or task_unit_test_mode in {"ut", "tdd"} or is_tdd_init
141
+ )
142
+ readiness = (
143
+ tdd_readiness(root)
144
+ if should_check_readiness
145
+ else {"status": "not_checked", "reasons": []}
146
+ )
147
+
148
+ layers = behavior_layers(root, resolved_session)
149
+ local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
150
+ return {
151
+ "session_file": display_path(root, session_path),
152
+ "behavior_sources": {key: item["source"] for key, item in layers.items()},
153
+ "local_behavior": local,
154
+ "effective_cooperate_mode": layers["cooperate_mode"]["value"],
155
+ "cooperation": task.get("cooperation") if task else None,
156
+ "quality_repair": task.get("quality_repair") if task else None,
157
+ "continuation": task.get("continuation") if task else None,
158
+ "current_task": str(task_id) if task_id else None,
159
+ "task": task,
160
+ "pending_transition": task.get("pending_transition") if task else None,
161
+ "memory_progress": task.get("memory_progress") if task else None,
162
+ "task_missing": missing,
163
+ "status": status,
164
+ "is_terminal": status in TERMINAL_STATUSES,
165
+ "last_agent": task.get("last_agent") if task else None,
166
+ "project_init_required": is_project_init_required(root),
167
+ "pending_init_version": get_pending_init_version(root),
168
+ "project_approval_mode": project_approval_mode,
169
+ "session_approval_mode": session_approval_mode,
170
+ "effective_approval_mode": effective_approval_mode,
171
+ "project_workflow_mode": project_workflow_mode,
172
+ "session_workflow_mode": session_workflow_mode,
173
+ "configured_workflow_mode": configured_workflow_mode,
174
+ "concrete_workflow_mode": concrete_workflow_mode,
175
+ "project_unit_test_mode": project_unit_test_mode,
176
+ "session_unit_test_mode": session_unit_test_mode,
177
+ "effective_unit_test_mode": effective_unit_test_mode,
178
+ "project_ut_coverage_threshold": project_ut_coverage_threshold,
179
+ "session_ut_coverage_threshold": session_ut_coverage_threshold,
180
+ "effective_ut_coverage_threshold": effective_ut_coverage_threshold,
181
+ "task_unit_test_mode": task_unit_test_mode,
182
+ "task_ut_coverage_threshold": task_ut_coverage_threshold,
183
+ "task_tdd_baselines": task.get("tdd_baselines") if task else None,
184
+ "displayed_unit_test_mode": displayed_unit_test_mode,
185
+ "displayed_ut_coverage_threshold": displayed_ut_threshold,
186
+ "unit_test_readiness_status": readiness["status"],
187
+ "unit_test_readiness_reasons": readiness["reasons"],
188
+ "spec_summary": spec_task_summary(task),
189
+ # Compatibility output aliases for pre-0.9 clients.
190
+ "project_confirm_mode": project_approval_mode,
191
+ "session_confirm_mode": session_approval_mode,
192
+ "effective_confirm_mode": effective_approval_mode,
193
+ "harness_disabled": resolved_session.get("harness_disabled") is True,
194
+ "lite_mode": resolved_session.get("lite_mode") is True,
195
+ "lite_proposal": resolved_session.get("lite_proposal"),
196
+ }
197
+
198
+
199
+ def build_status_line(
200
+ root: Path,
201
+ session: dict,
202
+ agent: str | None = None,
203
+ session_file: str | Path | None = None,
204
+ state: dict | None = None,
205
+ ) -> str:
206
+ state = state if state is not None else snapshot_state(root, session_file, session)
207
+ if state["lite_mode"]:
208
+ lite_state = (
209
+ "Awaiting Confirmation"
210
+ if isinstance(state.get("lite_proposal"), dict)
211
+ and not state["lite_proposal"].get("confirmed_at")
212
+ else "Ready"
213
+ )
214
+ return (
215
+ f"> **Easy Coding** · **Lite Direct** · {lite_state} · "
216
+ "No Task / Quality / Memory · Use `ec-lite` to exit"
217
+ )
218
+ approval = str(state["effective_approval_mode"]).capitalize()
219
+ workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
220
+ status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
221
+ if state["effective_cooperate_mode"] == "dispatch":
222
+ status_brand += " · **Dispatch**"
223
+ if state["displayed_unit_test_mode"] in {"ut", "tdd"}:
224
+ status_brand += f" · **{state['displayed_unit_test_mode'].upper()}**"
225
+ task_id = state["current_task"]
226
+ if task_id:
227
+ status = str(state["status"])
228
+ line = f"{status_brand} · `{task_id}` · `{status}`"
229
+ handoff = pending_handoff_record(root, str(task_id))
230
+ handoff_from = handoff.get("from") if handoff else None
231
+ if agent and handoff_from and not agents_equivalent(handoff_from, agent):
232
+ line += f" · Handoff -> `{handoff_from}`"
233
+ if state["is_terminal"] or state["task_missing"]:
234
+ line += f" · {HELP_SUFFIX}"
235
+ return line
236
+
237
+ if is_project_init_required(root):
238
+ return f"{status_brand} · {WAITING_INIT_LINE}"
239
+
240
+ pending = get_pending_init_version(root)
241
+ if pending:
242
+ return (
243
+ f"{status_brand} · Waiting init · "
244
+ f"Upgrade to v{pending} — run `ec-init` to adapt"
245
+ )
246
+
247
+ return f"{status_brand} · {READY_LINE}"
248
+
249
+
250
+ def build_machine_breadcrumbs(
251
+ root: Path,
252
+ session: dict,
253
+ agent: str | None = None,
254
+ session_file: str | Path | None = None,
255
+ state: dict | None = None,
256
+ ) -> list[str]:
257
+ state = state if state is not None else snapshot_state(root, session_file, session)
258
+ task_id = state["current_task"]
259
+ task = state["task"]
260
+ stage = str(state["status"]) if task else "idle"
261
+ resolved_session_file = str(state["session_file"])
262
+ lines = [
263
+ f"[workflow-state:{stage}]",
264
+ f"[easy-coding:session-file:{resolved_session_file}]",
265
+ f"[easy-coding:approval-mode:{state['effective_approval_mode']}]",
266
+ f"[easy-coding:configured-workflow-mode:{state['configured_workflow_mode']}]",
267
+ f"[easy-coding:cooperate-mode:{state['effective_cooperate_mode']}]",
268
+ ]
269
+ if state.get("concrete_workflow_mode"):
270
+ lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
271
+ if state.get("displayed_unit_test_mode") in {"ut", "tdd"}:
272
+ lines.append(f"[easy-coding:unit-test-mode:{state['displayed_unit_test_mode']}]")
273
+ lines.append(
274
+ f"[easy-coding:ut-coverage-threshold:{state['displayed_ut_coverage_threshold']}]"
275
+ )
276
+
277
+ if task_id:
278
+ lines.append(f"[current-task:{task_id}]")
279
+ continuation = state.get("continuation") or {}
280
+ if continuation.get("next_action"):
281
+ lines.append(f"[easy-coding:next-action:{continuation['next_action']}]")
282
+ if continuation.get("stop_after"):
283
+ lines.append(f"[easy-coding:stop-after:{continuation['stop_after']}]")
284
+ if task and isinstance(task.get("spec_source"), dict):
285
+ source = task["spec_source"]
286
+ lines.append(f"[easy-coding:spec:{source.get('spec_id')}:revision:{source.get('revision')}]")
287
+ lines.append("[easy-coding:spec-context:reuse-current-session-or-resume-if-missing]")
288
+ if task.get("spec_change"):
289
+ lines.append("[easy-coding:spec-change:pending-sync-spec-design]")
290
+ if (task.get("spec_writeback_progress") or {}).get("pending_action"):
291
+ lines.append("[easy-coding:spec-writeback:reconcile-spec-execution-required]")
292
+ if state["task_missing"]:
293
+ lines.append(f"[easy-coding:current-task-missing:{task_id}]")
294
+ handoff = pending_handoff_record(root, str(task_id))
295
+ handoff_from = handoff.get("from") if handoff else None
296
+ if agent and handoff_from and not agents_equivalent(handoff_from, agent):
297
+ lines.append(f"[easy-coding:handoff-from:{handoff_from}]")
298
+ pending = state.get("pending_transition")
299
+ if isinstance(pending, dict):
300
+ source = str(pending.get("from") or stage)
301
+ target = str(pending.get("to") or "")
302
+ if target:
303
+ lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
304
+ task_type = str(task.get("type") or "") if task else ""
305
+ if pending.get("confirmation_override") == "evidence-drift":
306
+ lines.append(
307
+ "[easy-coding:acceptance-drift-confirmation-required]"
308
+ )
309
+ lines.append("[easy-coding:transition-confirmation-required]")
310
+ elif is_automatic_transition(
311
+ source,
312
+ target,
313
+ task_type,
314
+ str(state["effective_approval_mode"]),
315
+ ):
316
+ lines.append(f"[easy-coding:auto-transition-ready:{source}->{target}]")
317
+ else:
318
+ lines.append("[easy-coding:transition-confirmation-required]")
319
+
320
+ if is_project_init_required(root):
321
+ lines.append("[easy-coding:init-required]")
322
+ else:
323
+ pending = get_pending_init_version(root)
324
+ if pending:
325
+ lines.append(f"[easy-coding:upgrade-init-pending:{pending}]")
326
+
327
+ # Stage-specific reminders
328
+ if stage == "ANALYSIS" and task_id:
329
+ dev_spec = root / ".easy-coding" / "tasks" / str(task_id) / "dev-spec.md"
330
+ if dev_spec.exists():
331
+ try:
332
+ content = dev_spec.read_text(encoding="utf-8")
333
+ missing = [h for h in MANDATORY_DEV_SPEC_HEADERS if h not in content]
334
+ if missing:
335
+ names = ",".join(h.lstrip("#").strip() for h in missing)
336
+ lines.append(f"[easy-coding:analysis-template-drift:missing:{names}]")
337
+ else:
338
+ lines.append("[easy-coding:analysis-template-ok]")
339
+ except OSError:
340
+ lines.append("[easy-coding:analysis-gate:skeleton-first-then-fill]")
341
+ else:
342
+ lines.append("[easy-coding:analysis-gate:skeleton-first-then-fill]")
343
+
344
+ # State machine validation
345
+ if task_id and task and task.get("status"):
346
+ current_stage = str(task["status"])
347
+ last_seen = session.get("last_seen_stage")
348
+ violation = record_seen_stage(root, str(task_id), current_stage, resolved_session_file)
349
+ if violation:
350
+ lines.append(f"[ILLEGAL-TRANSITION:{last_seen}->{current_stage}]")
351
+ lines.append(f"[easy-coding:transition-error:{violation}]")
352
+
353
+ return lines
354
+
355
+
356
+ def build_status_context(
357
+ root: Path,
358
+ session: dict,
359
+ agent: str | None = None,
360
+ session_file: str | Path | None = None,
361
+ state: dict | None = None,
362
+ ) -> str:
363
+ if session.get("harness_disabled") is True:
364
+ session_path = resolve_session_path(root, session_file)
365
+ return "\n".join(
366
+ [
367
+ "[easy-coding:no-harness]",
368
+ f"[easy-coding:session-file:{display_path(root, session_path)}]",
369
+ ]
370
+ )
371
+ if session.get("lite_mode") is True:
372
+ session_path = resolve_session_path(root, session_file)
373
+ proposal = session.get("lite_proposal")
374
+ lines = [
375
+ build_status_line(root, session, agent, session_file),
376
+ "[easy-coding:lite-direct]",
377
+ f"[easy-coding:session-file:{display_path(root, session_path)}]",
378
+ ]
379
+ if isinstance(proposal, dict):
380
+ lines.append(f"[easy-coding:lite-proposal:{proposal.get('digest', 'missing')}]")
381
+ return "\n".join(lines)
382
+ state = state if state is not None else snapshot_state(root, session_file, session)
383
+ return "\n".join(
384
+ [
385
+ build_status_line(root, session, agent, session_file, state),
386
+ *build_machine_breadcrumbs(root, session, agent, session_file, state),
387
+ ]
388
+ )
389
+
390
+
391
+ def record_seen_stage(
392
+ root: Path,
393
+ task_id: str | None,
394
+ stage: str,
395
+ session_file: str | Path | None = None,
396
+ ) -> str | None:
397
+ if not task_id or stage in {"idle", "MISSING"}:
398
+ return None
399
+ session = ensure_session(root, session_file)
400
+ last_seen_task = session.get("last_seen_task")
401
+ last_seen_stage = session.get("last_seen_stage")
402
+
403
+ violation = None
404
+ if last_seen_task == task_id and last_seen_stage:
405
+ task = load_task(root, task_id)
406
+ task_type = str(task.get("type") or "") if task else ""
407
+ violation = validate_transition(str(last_seen_stage), stage, task_type, task)
408
+
409
+ if last_seen_task != task_id or last_seen_stage != stage:
410
+ session["last_seen_task"] = task_id
411
+ session["last_seen_stage"] = stage
412
+ write_session(root, session, session_file)
413
+
414
+ return violation