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.
- package/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +5 -1
- package/templates/common/skills/ec-implementing/SKILL.md +3 -0
- package/templates/common/skills/ec-quality/SKILL.md +11 -8
- package/templates/common/skills/ec-workflow/SKILL.md +3 -0
- package/templates/shared-hooks/easy_coding_inputs.py +55 -38
- package/templates/shared-hooks/easy_coding_operation.py +39 -0
- package/templates/shared-hooks/easy_coding_state.py +177 -1737
- package/templates/shared-hooks/easy_coding_status.py +412 -5
- package/templates/shared-hooks/easy_coding_store.py +1193 -0
- package/templates/shared-hooks/easy_dev_spec.py +5 -4
- package/templates/shared-hooks/inject-subagent-context.py +6 -1
- package/templates/shared-hooks/inject-workflow-state.py +6 -2
- package/templates/shared-hooks/session-start.py +6 -2
|
@@ -1,7 +1,414 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|