github-proyect-tracking 0.0.0-stage → 0.4.1

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.
@@ -0,0 +1,846 @@
1
+ """Implementación de comandos sobre una instantánea remota coherente."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import json
7
+ import sys
8
+ import urllib.parse
9
+ from pathlib import Path
10
+
11
+ from . import github
12
+ from .common import (
13
+ EXIT_CONFLICT, EXIT_NO_PROCESS, EXIT_NO_TOKEN, EXIT_NOT_FOUND, EXIT_OK,
14
+ EXIT_RESUME_REQUIRED, EXIT_RUNTIME, EXIT_TOKEN_INSUFFICIENT,
15
+ EXIT_VALIDATION, MAX_DETAILS_LEN, MAX_PLAN_STEPS, SCHEMA_VERSION,
16
+ STATUS_CANCELADO, STATUS_COMPLETADO, STATUS_ERROR, STATUS_FINALIZADO,
17
+ STATUS_INICIANDO, SkillError, emit, new_execution_id, normalize_step_name,
18
+ now_iso, ordered_unique, redact_url, sanitize_text, validate_repo_url,
19
+ )
20
+ from .events import (
21
+ build_issue_body, events_from_comments, fmt_cancelado, fmt_completado,
22
+ fmt_error, fmt_finalizado, fmt_iniciando, parse_plan_from_body, plan_resume,
23
+ _validate_resume_plan,
24
+ )
25
+ from .state import (
26
+ StateLock, is_unfinished, list_state_files, load_state, public_state,
27
+ save_state, select_state_path, state_dir, state_file_for,
28
+ )
29
+
30
+
31
+ def resolve_target(args):
32
+ url = getattr(args, "url", None)
33
+ if url:
34
+ target = validate_repo_url(url)
35
+ if not target:
36
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
37
+ f"github_project_url inválido: {redact_url(url)} "
38
+ "(se requiere https://github.com/{owner}/{repo})")
39
+ owner, repo = target
40
+ return owner, repo, state_file_for(owner, repo)
41
+ path = select_state_path()
42
+ probe = load_state(path)
43
+ return probe["owner"], probe["repo"], path
44
+
45
+
46
+ def load_active_state(path):
47
+ state = load_state(path)
48
+ if not (state and state.get("active_process_id")):
49
+ raise SkillError(EXIT_NO_PROCESS, "NO_ACTIVE_PROCESS",
50
+ f"no hay proceso activo en {path}; ejecuta init primero")
51
+ return state
52
+
53
+
54
+ def need_token(args, state=None):
55
+ token, source = github.resolve_token(getattr(args, "token", None), state)
56
+ if not token:
57
+ raise SkillError(EXIT_NO_TOKEN, "NO_TOKEN",
58
+ "no hay token de GitHub disponible. Fuentes aceptadas: "
59
+ "--token, $GITHUB_TRACKING_TOKEN, $GITHUB_TOKEN, $GH_TOKEN, "
60
+ "'gh auth token' o el archivo de estado (--store-token).",
61
+ {"how_to_fix": "pide al usuario un PAT con issues:write y "
62
+ "reintenta con --token o GITHUB_TOKEN"})
63
+ return token, source
64
+
65
+
66
+ def load_plan(args):
67
+ raw = None
68
+ if getattr(args, "plan", None):
69
+ raw = args.plan
70
+ elif getattr(args, "plan_file", None):
71
+ try:
72
+ raw = Path(args.plan_file).read_text(encoding="utf-8")
73
+ except OSError as error:
74
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
75
+ f"no se pudo leer --plan-file: {error}")
76
+ if raw is None:
77
+ return []
78
+ try:
79
+ plan = json.loads(raw)
80
+ except json.JSONDecodeError as error:
81
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
82
+ f"--plan no es JSON válido: {error}")
83
+ if not isinstance(plan, list) or not all(isinstance(step, str) for step in plan):
84
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
85
+ '--plan debe ser una lista JSON de cadenas, '
86
+ 'ej. \'["Paso 1: análisis", "Paso 2: build"]\'')
87
+ if len(plan) > MAX_PLAN_STEPS:
88
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
89
+ f"el plan no puede exceder {MAX_PLAN_STEPS} pasos")
90
+ plan = [normalize_step_name(step) for step in plan]
91
+ if len(plan) != len(set(plan)):
92
+ duplicates = sorted({step for step in plan if plan.count(step) > 1})
93
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
94
+ f"el plan contiene pasos duplicados (tras normalizar): "
95
+ f"{duplicates}; usa nombres únicos")
96
+ return plan
97
+
98
+
99
+ def _remote_snapshot(owner, repo, number, token):
100
+ issue = github.fetch_issue(owner, repo, number, token)
101
+ comments = github.fetch_all_comments(owner, repo, number, token)
102
+ events = events_from_comments(comments)
103
+ return issue, events, comments
104
+
105
+
106
+ def _plan_from_any(state, issue):
107
+ plan = state.get("plan")
108
+ if plan is None:
109
+ plan = parse_plan_from_body(issue.get("body") or "")
110
+ return _validate_resume_plan(plan)
111
+
112
+
113
+ def _completed_from_events(events):
114
+ return ordered_unique(
115
+ event["step"] for event in events
116
+ if event["status"] == STATUS_COMPLETADO and event.get("step"))
117
+
118
+
119
+ def cmd_init(args):
120
+ target = validate_repo_url(args.url)
121
+ if not target:
122
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
123
+ f"github_project_url inválido: {redact_url(args.url)} "
124
+ "(se requiere https://github.com/{owner}/{repo})")
125
+ owner, repo = target
126
+ path = state_file_for(owner, repo)
127
+ plan = load_plan(args)
128
+
129
+ lock = StateLock(path)
130
+ with lock:
131
+ existing = load_state(path)
132
+ if is_unfinished(existing):
133
+ raise SkillError(EXIT_RESUME_REQUIRED, "RESUME_REQUIRED",
134
+ f"ya existe un proceso inconcluso (Issue "
135
+ f"#{existing['active_process_id']}, estado "
136
+ f"{existing['process_state']}) en la memoria interna "
137
+ f"para {owner}/{repo}. Ejecuta 'resume' para reanudarlo.",
138
+ {"state": public_state(existing), "next": ["resume"]})
139
+
140
+ token, source = github.resolve_token(args.token, existing)
141
+ if not token:
142
+ if args.prompt_token and sys.stdin.isatty():
143
+ token = getpass.getpass("GitHub token (issues:write): ")
144
+ source = "prompt"
145
+ else:
146
+ raise SkillError(EXIT_NO_TOKEN, "NO_TOKEN",
147
+ "no hay token de GitHub disponible para crear el Issue.",
148
+ {"how_to_fix": "pide al usuario un PAT con permisos "
149
+ "issues:write y reintenta con --token "
150
+ "o GITHUB_TOKEN"})
151
+ ok, code, detail = github.check_issues_write(owner, repo, token)
152
+ if not ok:
153
+ raise SkillError(EXIT_TOKEN_INSUFFICIENT, code, detail)
154
+ store = bool(args.store_token) and source in ("cli", "prompt")
155
+
156
+ execution_id = new_execution_id()
157
+ title = f"[Process Tracking] Execution ID: {execution_id}"
158
+ body = build_issue_body(args.url.strip(), execution_id, plan)
159
+ recovered = False
160
+ create_error = None
161
+ lock.ensure_owner()
162
+ try:
163
+ status, data, _ = github.api(
164
+ "POST", f"/repos/{owner}/{repo}/issues", token,
165
+ {"title": title, "body": body})
166
+ if status in github.AMBIGUOUS_HTTP_STATUSES:
167
+ create_error = SkillError(
168
+ EXIT_RUNTIME, "ISSUE_CREATE_AMBIGUOUS",
169
+ f"el servidor devolvió {status}; el Issue pudo haberse creado")
170
+ elif status not in (200, 201):
171
+ if status == 403:
172
+ raise SkillError(EXIT_TOKEN_INSUFFICIENT, "ISSUES_WRITE_FAILED",
173
+ f"no se pudo crear el Issue (403): "
174
+ f"{data.get('message', '')} — el token no puede "
175
+ "escribir Issues en este repositorio")
176
+ raise SkillError(EXIT_RUNTIME, "ISSUE_CREATE_FAILED",
177
+ f"no se pudo crear el Issue ({status}): "
178
+ f"{data.get('message', '')}")
179
+ except SkillError as error:
180
+ if error.code != "NETWORK_ERROR":
181
+ raise
182
+ create_error = error
183
+ if create_error is not None:
184
+ issue = github.find_issue_by_title(owner, repo, title, token)
185
+ if issue is None:
186
+ extra = dict(create_error.extra)
187
+ extra.update({
188
+ "execution_id": execution_id,
189
+ "issue_title": title,
190
+ "hint": f"si el Issue existe, recupéralo con: adopt --url "
191
+ f"{args.url} --execution-id {execution_id}; no repitas init "
192
+ "hasta comprobarlo",
193
+ })
194
+ raise SkillError(create_error.exit_code, create_error.code,
195
+ create_error.message, extra)
196
+ data, recovered = issue, True
197
+ number = data["number"]
198
+
199
+ state = {
200
+ "schema_version": SCHEMA_VERSION,
201
+ "github_project_url": f"https://github.com/{owner}/{repo}",
202
+ "owner": owner, "repo": repo,
203
+ "active_process_id": number,
204
+ "execution_id": execution_id,
205
+ "issue_url": data.get("html_url"),
206
+ "process_state": "RUNNING",
207
+ "current_step": None,
208
+ "completed_steps": [],
209
+ "plan": plan,
210
+ "github_token": token if store else (existing or {}).get("github_token"),
211
+ "created_at": now_iso(),
212
+ "history": (existing or {}).get("history", []),
213
+ }
214
+ lock.ensure_owner()
215
+ try:
216
+ save_state(path, state)
217
+ except SkillError as error:
218
+ raise SkillError(EXIT_RUNTIME, "STATE_SAVE_FAILED", error.message, {
219
+ "execution_id": execution_id, "issue_number": number,
220
+ "issue_url": data.get("html_url"),
221
+ "hint": f"el Issue se creó pero la memoria local no se guardó; "
222
+ f"recupera con: adopt --url {args.url} --execution-id {execution_id}"})
223
+ emit({
224
+ "ok": True, "action": "INIT",
225
+ "execution_id": execution_id, "issue_number": number,
226
+ "issue_url": data.get("html_url"), "state_file": str(path),
227
+ "plan": plan, "first_step": plan[0] if plan else None,
228
+ "recovered": recovered,
229
+ "token_source": source, "token_stored": bool(state["github_token"]),
230
+ })
231
+ return EXIT_OK
232
+
233
+
234
+ def cmd_resume(args):
235
+ owner, repo, path = resolve_target(args)
236
+ lock = StateLock(path)
237
+ with lock:
238
+ state = load_active_state(path)
239
+ number = state["active_process_id"]
240
+ token, _ = need_token(args, state)
241
+ issue, events, comments = _remote_snapshot(owner, repo, number, token)
242
+ plan = state.get("plan") or parse_plan_from_body(issue.get("body") or "")
243
+ verdict = plan_resume(events, plan, issue.get("state", "open"))
244
+
245
+ action = verdict["action"]
246
+ if action == "NONE":
247
+ state["active_process_id"] = None
248
+ state["process_state"] = (
249
+ "CANCELADO" if verdict.get("outcome") == "CANCELADO"
250
+ else "FINALIZED")
251
+ elif action == "ABORTED_EXTERNALLY":
252
+ state["active_process_id"] = None
253
+ state["process_state"] = "ABORTED"
254
+ state["aborted"] = {"issue_number": number,
255
+ "issue_url": issue.get("html_url"),
256
+ "at": now_iso()}
257
+ else:
258
+ last_event = events[-1] if events else None
259
+ state["process_state"] = (
260
+ "ERROR" if last_event and last_event["status"] == STATUS_ERROR
261
+ else "RUNNING")
262
+ state["completed_steps"] = verdict.get("completed_steps", [])
263
+ state["current_step"] = verdict.get("resume_from_step")
264
+ last_event = events[-1] if events else None
265
+ state["last_resume"] = {
266
+ "verdict": action,
267
+ "at": now_iso(),
268
+ "issue_number": number,
269
+ "event_comment_id": last_event.get("comment_id") if last_event else None,
270
+ "event_transition_id": last_event.get("transition_id") if last_event else None,
271
+ "failed_step": verdict.get("failed_step"),
272
+ }
273
+ lock.ensure_owner()
274
+ save_state(path, state)
275
+ emit({
276
+ "ok": True, "action": "RESUME",
277
+ "owner": owner, "repo": repo, "issue_number": number,
278
+ "issue_url": issue.get("html_url"),
279
+ "issue_state": issue.get("state"),
280
+ "comment_count": len(comments), "verdict": verdict,
281
+ "state_file": str(path),
282
+ })
283
+ return EXIT_OK
284
+
285
+
286
+ def cmd_step_start(args):
287
+ step = normalize_step_name(args.name)
288
+ owner, repo, path = resolve_target(args)
289
+ lock = StateLock(path)
290
+ with lock:
291
+ state = load_active_state(path)
292
+ token, _ = need_token(args, state)
293
+ number = state["active_process_id"]
294
+ issue, events, comments = _remote_snapshot(owner, repo, number, token)
295
+ if issue.get("state") != "open":
296
+ raise SkillError(EXIT_CONFLICT, "ISSUE_CLOSED_EXTERNALLY",
297
+ f"el Issue #{number} fue cerrado externamente",
298
+ {"issue_url": issue.get("html_url")})
299
+ last = events[-1] if events else None
300
+ if last and last["status"] in (STATUS_FINALIZADO, STATUS_CANCELADO):
301
+ raise SkillError(EXIT_CONFLICT, "ALREADY_FINALIZED",
302
+ f"el último evento remoto es {last['status']}; "
303
+ "usa finalize para cerrar y limpiar memoria")
304
+
305
+ completed = _completed_from_events(events)
306
+ plan = _plan_from_any(state, issue)
307
+ if last and last["status"] == STATUS_ERROR:
308
+ resumed = state.get("last_resume") or {}
309
+ authorized = (
310
+ resumed.get("verdict") == "RERUN_STEP"
311
+ and resumed.get("issue_number") == number
312
+ and resumed.get("event_comment_id") == last.get("comment_id")
313
+ and resumed.get("failed_step") == last.get("step"))
314
+ if not authorized:
315
+ raise SkillError(EXIT_RESUME_REQUIRED, "RESUME_REQUIRED",
316
+ "el último evento remoto es ERROR; ejecuta "
317
+ "'resume' antes de reintentar el paso")
318
+ if step != last.get("step"):
319
+ raise SkillError(EXIT_VALIDATION, "STEP_OUT_OF_ORDER",
320
+ f"después de ERROR debes reintentar '{last.get('step')}', "
321
+ f"no '{step}'")
322
+ if last and last["status"] == STATUS_INICIANDO \
323
+ and last.get("step") and last["step"] != step:
324
+ raise SkillError(EXIT_VALIDATION, "STEP_IN_FLIGHT",
325
+ f"el paso '{last['step']}' está en curso; completa "
326
+ "o erra ese paso antes de iniciar otro")
327
+ if plan:
328
+ if step in completed:
329
+ raise SkillError(EXIT_VALIDATION, "STEP_ALREADY_DONE",
330
+ f"el paso '{step}' ya está COMPLETADO",
331
+ {"completed_steps": completed})
332
+ if step not in plan:
333
+ raise SkillError(EXIT_VALIDATION, "STEP_NOT_IN_PLAN",
334
+ f"el paso '{step}' no pertenece al plan",
335
+ {"plan": plan})
336
+ expected = next((item for item in plan if item not in completed), None)
337
+ if expected and step != expected:
338
+ raise SkillError(EXIT_VALIDATION, "STEP_OUT_OF_ORDER",
339
+ f"el siguiente paso del plan es '{expected}', "
340
+ f"no '{step}'",
341
+ {"expected_step": expected,
342
+ "completed_steps": completed})
343
+
344
+ baseline = frozenset(comment.get("id") for comment in comments)
345
+ lock.ensure_owner()
346
+ comment = github.post_comment(
347
+ owner, repo, number, fmt_iniciando(step), token,
348
+ baseline_ids=baseline, execution_id=state.get("execution_id"),
349
+ sequence=len(events) + 1)
350
+ state["current_step"] = step
351
+ state["process_state"] = "RUNNING"
352
+ state["completed_steps"] = completed
353
+ state["plan"] = plan
354
+ lock.ensure_owner()
355
+ try:
356
+ save_state(path, state)
357
+ except SkillError as error:
358
+ raise SkillError(EXIT_RUNTIME, "STATE_SAVE_FAILED", error.message, {
359
+ "comment_url": comment["comment_url"],
360
+ "hint": "el comentario se publicó; ejecuta resume para "
361
+ "re-sincronizar la memoria desde el Issue"})
362
+ emit({"ok": True, "action": "STEP_START", "step": step,
363
+ "issue_number": number,
364
+ "comment_url": comment["comment_url"],
365
+ "reconciled": comment["reconciled"],
366
+ "process_state": "RUNNING", "state_file": str(path)})
367
+ return EXIT_OK
368
+
369
+
370
+ def _step_guard_check(issue, events, number):
371
+ if issue.get("state") != "open":
372
+ raise SkillError(EXIT_CONFLICT, "ISSUE_CLOSED_EXTERNALLY",
373
+ f"el Issue #{number} fue cerrado externamente",
374
+ {"issue_url": issue.get("html_url")})
375
+ last = events[-1] if events else None
376
+ if last and last["status"] == STATUS_ERROR:
377
+ raise SkillError(EXIT_RESUME_REQUIRED, "RESUME_REQUIRED",
378
+ "el último evento remoto es ERROR; ejecuta 'resume' "
379
+ "antes de cualquier otra transición")
380
+ if last and last["status"] in (STATUS_FINALIZADO, STATUS_CANCELADO):
381
+ raise SkillError(EXIT_CONFLICT, "ALREADY_FINALIZED",
382
+ f"el último evento remoto es {last['status']}; usa "
383
+ "finalize para cerrar y limpiar memoria")
384
+
385
+
386
+ def _require_in_flight(last, step, what):
387
+ if not last or last["status"] != STATUS_INICIANDO \
388
+ or last.get("step") != step:
389
+ got = (f"{last['status']} - Paso: {last.get('step')}" if last
390
+ else "sin eventos")
391
+ raise SkillError(EXIT_VALIDATION, "NO_STEP_IN_FLIGHT",
392
+ f"{what} requiere que el último evento remoto sea "
393
+ f"'[STATUS: INICIANDO] - Paso: {step}'; el actual es: {got}. "
394
+ "Ejecuta step-start primero (o resume si el estado es incierto)")
395
+
396
+
397
+ def _save_after_comment(lock, path, state, comment):
398
+ lock.ensure_owner()
399
+ try:
400
+ save_state(path, state)
401
+ except SkillError as error:
402
+ raise SkillError(EXIT_RUNTIME, "STATE_SAVE_FAILED", error.message, {
403
+ "comment_url": comment["comment_url"],
404
+ "hint": "el comentario se publicó; ejecuta resume para "
405
+ "re-sincronizar la memoria desde el Issue"})
406
+
407
+
408
+ def cmd_step_done(args):
409
+ step = normalize_step_name(args.name)
410
+ owner, repo, path = resolve_target(args)
411
+ lock = StateLock(path)
412
+ with lock:
413
+ state = load_active_state(path)
414
+ token, _ = need_token(args, state)
415
+ number = state["active_process_id"]
416
+ issue, events, comments = _remote_snapshot(owner, repo, number, token)
417
+ _step_guard_check(issue, events, number)
418
+ last = events[-1] if events else None
419
+ _require_in_flight(last, step, "step-done")
420
+ baseline = frozenset(comment.get("id") for comment in comments)
421
+ lock.ensure_owner()
422
+ comment = github.post_comment(
423
+ owner, repo, number, fmt_completado(step), token,
424
+ baseline_ids=baseline, execution_id=state.get("execution_id"),
425
+ sequence=len(events) + 1)
426
+ state["current_step"] = None
427
+ state["process_state"] = "RUNNING"
428
+ state["completed_steps"] = ordered_unique(
429
+ _completed_from_events(events) + [step])
430
+ _save_after_comment(lock, path, state, comment)
431
+ emit({"ok": True, "action": "STEP_DONE", "step": step,
432
+ "issue_number": number, "comment_url": comment["comment_url"],
433
+ "reconciled": comment["reconciled"], "process_state": "RUNNING",
434
+ "state_file": str(path)})
435
+ return EXIT_OK
436
+
437
+
438
+ def cmd_step_error(args):
439
+ step = normalize_step_name(args.name)
440
+ if getattr(args, "details", None):
441
+ details = args.details
442
+ elif getattr(args, "details_file", None):
443
+ try:
444
+ details = Path(args.details_file).read_text(encoding="utf-8",
445
+ errors="replace")
446
+ except OSError as error:
447
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
448
+ f"no se pudo leer --details-file: {error}")
449
+ else:
450
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
451
+ "step-error requiere --details o --details-file "
452
+ "(mensaje de error o stacktrace)")
453
+ details = details.strip()
454
+ if not details:
455
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
456
+ "los detalles del error no pueden estar vacíos")
457
+ details, redacted = sanitize_text(details)
458
+ details = details[:MAX_DETAILS_LEN]
459
+
460
+ owner, repo, path = resolve_target(args)
461
+ lock = StateLock(path)
462
+ with lock:
463
+ state = load_active_state(path)
464
+ token, _ = need_token(args, state)
465
+ number = state["active_process_id"]
466
+ issue, events, comments = _remote_snapshot(owner, repo, number, token)
467
+ _step_guard_check(issue, events, number)
468
+ last = events[-1] if events else None
469
+ _require_in_flight(last, step, "step-error")
470
+ baseline = frozenset(comment.get("id") for comment in comments)
471
+ lock.ensure_owner()
472
+ comment = github.post_comment(
473
+ owner, repo, number, fmt_error(step, details), token,
474
+ baseline_ids=baseline, execution_id=state.get("execution_id"),
475
+ sequence=len(events) + 1)
476
+ state["current_step"] = step
477
+ state["process_state"] = "ERROR"
478
+ state["last_error"] = {"step": step, "at": now_iso()}
479
+ _save_after_comment(lock, path, state, comment)
480
+ output = {"ok": True, "action": "STEP_ERROR", "step": step,
481
+ "issue_number": number, "comment_url": comment["comment_url"],
482
+ "reconciled": comment["reconciled"],
483
+ "process_state": "ERROR", "stopped": True,
484
+ "state_file": str(path)}
485
+ if redacted:
486
+ output["redacted_secrets"] = redacted
487
+ emit(output)
488
+ return EXIT_OK
489
+
490
+
491
+ _PENDING_VERDICTS = ("RERUN_STEP", "NEXT_STEP", "START_FIRST",
492
+ "NEXT_STEP_AGENT_DEFINED")
493
+
494
+
495
+ def cmd_finalize(args):
496
+ owner, repo, path = resolve_target(args)
497
+ lock = StateLock(path)
498
+ with lock:
499
+ state = load_active_state(path)
500
+ number = state["active_process_id"]
501
+ token, _ = need_token(args, state)
502
+ issue, events, comments = _remote_snapshot(owner, repo, number, token)
503
+ plan = _plan_from_any(state, issue)
504
+ verdict = plan_resume(events, plan, issue.get("state", "open"))
505
+ action = verdict["action"]
506
+ completed = verdict.get("completed_steps", [])
507
+ if action == "ABORTED_EXTERNALLY":
508
+ raise SkillError(EXIT_CONFLICT, "ABORTED_EXTERNALLY",
509
+ "el Issue fue cerrado sin comentario de cierre; no "
510
+ "se marca como exitoso. Decide con el usuario: "
511
+ "reabrir y continuar, o clear --hard para descartar",
512
+ {"issue_url": issue.get("html_url"),
513
+ "verdict": verdict})
514
+ last = events[-1] if events else None
515
+ already_done = bool(last) and last["status"] == STATUS_FINALIZADO
516
+ already_cancel = bool(last) and last["status"] == STATUS_CANCELADO
517
+ if last and last["status"] == STATUS_ERROR and not args.force:
518
+ raise SkillError(EXIT_RESUME_REQUIRED, "RESUME_REQUIRED",
519
+ "el último evento remoto es ERROR; ejecuta 'resume' "
520
+ "y completa el paso fallido antes de finalizar "
521
+ "(o --force para cancelar)")
522
+
523
+ no_plan_complete = (not plan and action == "NEXT_STEP_AGENT_DEFINED"
524
+ and last and last["status"] == STATUS_COMPLETADO)
525
+ pending_action = action in _PENDING_VERDICTS and not no_plan_complete
526
+ if pending_action and not args.force:
527
+ raise SkillError(EXIT_CONFLICT, "FINALIZE_BLOCKED",
528
+ "el proceso no ha completado todos los pasos",
529
+ {"verdict": verdict,
530
+ "hint": "completa los pasos pendientes, o usa "
531
+ "--force para cancelar ahora "
532
+ "(se registrará CANCELADO, no éxito)"})
533
+ forced = pending_action and args.force
534
+ outcome = "CANCELADO" if (forced or already_cancel) else "FINALIZADO"
535
+
536
+ lock.ensure_owner()
537
+ comment = None
538
+ if forced and not already_cancel:
539
+ pending = verdict.get("remaining_steps") or (
540
+ [step for step in plan if step not in completed] if plan else [])
541
+ comment = github.post_comment(
542
+ owner, repo, number, fmt_cancelado(pending), token,
543
+ baseline_ids=frozenset(item.get("id") for item in comments),
544
+ execution_id=state.get("execution_id"), sequence=len(events) + 1)
545
+ elif not already_done and not already_cancel and not forced:
546
+ comment = github.post_comment(
547
+ owner, repo, number, fmt_finalizado(), token,
548
+ baseline_ids=frozenset(item.get("id") for item in comments),
549
+ execution_id=state.get("execution_id"), sequence=len(events) + 1)
550
+ github.close_issue(owner, repo, number, token)
551
+ issue = github.fetch_issue(owner, repo, number, token)
552
+ if issue.get("state") != "closed":
553
+ raise SkillError(EXIT_RUNTIME, "CLOSE_FAILED",
554
+ f"el Issue #{number} no quedó cerrado tras el PATCH "
555
+ f"(state={issue.get('state')})")
556
+
557
+ history = state.get("history") or []
558
+ history.append({
559
+ "execution_id": state.get("execution_id"),
560
+ "issue_number": number,
561
+ "issue_url": state.get("issue_url") or issue.get("html_url"),
562
+ "finished_at": now_iso(),
563
+ "forced": outcome == "CANCELADO",
564
+ "outcome": outcome,
565
+ })
566
+ state.update({
567
+ "active_process_id": None,
568
+ "process_state": "CANCELADO" if outcome == "CANCELADO" else "FINALIZED",
569
+ "current_step": None,
570
+ "completed_steps": completed,
571
+ "github_token": state.get("github_token"),
572
+ "history": history[-20:],
573
+ })
574
+ lock.ensure_owner()
575
+ try:
576
+ save_state(path, state)
577
+ except SkillError as error:
578
+ raise SkillError(EXIT_RUNTIME, "STATE_SAVE_FAILED", error.message, {
579
+ "issue_url": issue.get("html_url"),
580
+ "hint": "el Issue está cerrado y finalizado/cancelado; ejecuta "
581
+ "clear para limpiar la memoria local"})
582
+ emit({
583
+ "ok": True, "action": "FINALIZE", "issue_number": number,
584
+ "issue_url": issue.get("html_url"), "issue_state": "closed",
585
+ "outcome": outcome,
586
+ "forced": outcome == "CANCELADO",
587
+ "comment_posted": bool(comment),
588
+ "comment_url": comment["comment_url"] if comment else None,
589
+ "completed_steps": completed,
590
+ "active_process_id": None,
591
+ "state_file": str(path),
592
+ })
593
+ return EXIT_OK
594
+
595
+
596
+ def cmd_status(args):
597
+ url = getattr(args, "url", None)
598
+ if url:
599
+ target = validate_repo_url(url)
600
+ if not target:
601
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
602
+ f"github_project_url inválido: {redact_url(url)}")
603
+ owner, repo = target
604
+ path = state_file_for(owner, repo)
605
+ state = load_state(path)
606
+ else:
607
+ try:
608
+ path = select_state_path()
609
+ except SkillError as error:
610
+ if error.code == "CONTEXT_AMBIGUOUS":
611
+ emit({"ok": True, "action": "STATUS",
612
+ "state": None, "state_files": [
613
+ item.name for item in list_state_files()],
614
+ "error_hint": {"code": error.code,
615
+ "message": error.message,
616
+ "active_repos": error.extra.get("active_repos")}})
617
+ return EXIT_OK
618
+ raise
619
+ state = load_state(path)
620
+ owner = state.get("owner") if state else None
621
+ repo = state.get("repo") if state else None
622
+
623
+ output = {"ok": True, "action": "STATUS",
624
+ "state_file": str(path) if path else None,
625
+ "state": public_state(state),
626
+ "state_files": [item.name for item in list_state_files()]}
627
+ if args.remote and state and state.get("active_process_id"):
628
+ token, _ = need_token(args, state)
629
+ number = state["active_process_id"]
630
+ issue = github.fetch_issue(owner, repo, number, token)
631
+ events = events_from_comments(
632
+ github.fetch_all_comments(owner, repo, number, token))
633
+ plan = state.get("plan") or parse_plan_from_body(issue.get("body") or "")
634
+ output["remote"] = {
635
+ "issue_number": number, "issue_state": issue.get("state"),
636
+ "verdict": plan_resume(events, plan, issue.get("state", "open")),
637
+ }
638
+ emit(output)
639
+ return EXIT_OK
640
+
641
+
642
+ def cmd_adopt(args):
643
+ target = validate_repo_url(args.url)
644
+ if not target:
645
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
646
+ f"github_project_url inválido: {redact_url(args.url)}")
647
+ owner, repo = target
648
+ path = state_file_for(owner, repo)
649
+ execution_id = (args.execution_id or "").strip()
650
+ if not execution_id:
651
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
652
+ "--execution-id es obligatorio (está en el título del Issue)")
653
+ existing = load_state(path)
654
+ token, _ = need_token(args, existing)
655
+
656
+ query = (f'repo:{owner}/{repo} "[Process Tracking] Execution ID: {execution_id}" '
657
+ f'in:title is:issue')
658
+ status, data, _ = github.api(
659
+ "GET", "/search/issues?" + urllib.parse.urlencode({"q": query, "per_page": 30}),
660
+ token)
661
+ if status != 200:
662
+ raise SkillError(EXIT_RUNTIME, "SEARCH_FAILED",
663
+ f"búsqueda de Issues devolvió {status}: "
664
+ f"{data.get('message', '')}")
665
+ matches = [issue for issue in (data.get("items") or [])
666
+ if github.canonical_title_match(issue, execution_id)]
667
+ if not matches:
668
+ raise SkillError(EXIT_NOT_FOUND, "ISSUE_NOT_FOUND",
669
+ f"no se encontró un Issue con título canónico para "
670
+ f"Execution ID {execution_id} en {owner}/{repo}")
671
+ if len(matches) > 1:
672
+ raise SkillError(EXIT_NOT_FOUND, "ISSUE_AMBIGUOUS",
673
+ "múltiples Issues con el mismo Execution ID; revisa "
674
+ "manualmente cuál adoptar",
675
+ {"issues": [issue.get("html_url") for issue in matches]})
676
+ issue = matches[0]
677
+
678
+ lock = StateLock(path)
679
+ with lock:
680
+ existing = load_state(path)
681
+ if is_unfinished(existing) and not args.force:
682
+ raise SkillError(EXIT_RESUME_REQUIRED, "ADOPT_CONFLICT",
683
+ f"ya hay un proceso activo (Issue "
684
+ f"#{existing['active_process_id']}); usa --force "
685
+ "para reemplazarlo (se conservan token e historial)",
686
+ {"active_issue": existing["active_process_id"],
687
+ "next": ["resume", "adopt --force"]})
688
+ number = issue["number"]
689
+ comments = github.fetch_all_comments(owner, repo, number, token)
690
+ events = events_from_comments(comments)
691
+ plan = parse_plan_from_body(issue.get("body") or "")
692
+ verdict = plan_resume(events, plan, issue.get("state", "open"))
693
+ action = verdict["action"]
694
+ if issue.get("state") == "open":
695
+ active = number
696
+ process_state = ("ERROR" if events and events[-1]["status"] == STATUS_ERROR
697
+ else "RUNNING")
698
+ elif action == "NONE":
699
+ active = None
700
+ process_state = ("CANCELADO" if verdict.get("outcome") == "CANCELADO"
701
+ else "FINALIZED")
702
+ else:
703
+ active, process_state = None, "ABORTED"
704
+
705
+ state = {
706
+ "schema_version": SCHEMA_VERSION,
707
+ "github_project_url": f"https://github.com/{owner}/{repo}",
708
+ "owner": owner, "repo": repo,
709
+ "active_process_id": active,
710
+ "execution_id": execution_id,
711
+ "issue_url": issue.get("html_url"),
712
+ "process_state": process_state,
713
+ "current_step": verdict.get("resume_from_step"),
714
+ "completed_steps": verdict.get("completed_steps", []),
715
+ "plan": plan,
716
+ "github_token": (existing or {}).get("github_token"),
717
+ "history": (existing or {}).get("history", []),
718
+ "created_at": now_iso(), "adopted": True,
719
+ }
720
+ if process_state == "ABORTED":
721
+ state["aborted"] = {"issue_number": number,
722
+ "issue_url": issue.get("html_url"),
723
+ "at": now_iso()}
724
+ lock.ensure_owner()
725
+ save_state(path, state)
726
+ emit({
727
+ "ok": True, "action": "ADOPT", "execution_id": execution_id,
728
+ "issue_number": number, "issue_url": issue.get("html_url"),
729
+ "issue_state": issue.get("state"), "verdict": verdict,
730
+ "process_state": process_state, "state_file": str(path),
731
+ })
732
+ return EXIT_OK
733
+
734
+
735
+ def cmd_clear(args):
736
+ url = getattr(args, "url", None)
737
+ if url:
738
+ target = validate_repo_url(url)
739
+ if not target:
740
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
741
+ f"github_project_url inválido: {redact_url(url)}")
742
+ path = state_file_for(*target)
743
+ else:
744
+ path = select_state_path(allow_recent=False)
745
+
746
+ lock = StateLock(path)
747
+ with lock:
748
+ state = load_state(path)
749
+ if state is None:
750
+ emit({"ok": True, "action": "CLEAR", "cleared": False,
751
+ "detail": f"no hay estado local en {path}"})
752
+ return EXIT_OK
753
+ was_unfinished = is_unfinished(state)
754
+ number = state.get("active_process_id")
755
+ lock.ensure_owner()
756
+ if args.hard:
757
+ try:
758
+ path.unlink()
759
+ except OSError as error:
760
+ raise SkillError(EXIT_RUNTIME, "STATE_UNREADABLE",
761
+ f"no se pudo eliminar {path}: {error}")
762
+ action = "CLEAR_HARD"
763
+ else:
764
+ state.update({"active_process_id": None,
765
+ "process_state": "CLEARED", "current_step": None,
766
+ "cleared_at": now_iso()})
767
+ save_state(path, state)
768
+ action = "CLEAR_SOFT"
769
+ output = {"ok": True, "action": action, "state_file": str(path)}
770
+ if was_unfinished and number:
771
+ output["warning"] = (f"se descartó un proceso INCONCLUSO (Issue #{number}); "
772
+ "el Issue permanece abierto en GitHub (usa 'adopt' "
773
+ "para recuperarlo si fue un error)")
774
+ emit(output)
775
+ return EXIT_OK
776
+
777
+
778
+ def cmd_doctor(args):
779
+ checks = []
780
+
781
+ def add(name, ok, detail, required=True):
782
+ checks.append({"check": name, "ok": bool(ok),
783
+ "detail": str(detail), "required": bool(required)})
784
+
785
+ add("python", sys.version_info >= (3, 8), sys.version.split()[0])
786
+ directory = state_dir()
787
+ try:
788
+ directory.mkdir(parents=True, exist_ok=True)
789
+ probe = directory / ".write-probe"
790
+ probe.write_text("ok", encoding="utf-8")
791
+ probe.unlink()
792
+ add("state-dir-writable", True,
793
+ f"{directory} (crea el directorio y un archivo de prueba: efecto de "
794
+ "escritura intencional)")
795
+ except OSError as error:
796
+ add("state-dir-writable", False, f"{directory}: {error}")
797
+
798
+ state_for_token = None
799
+ url = getattr(args, "url", None)
800
+ if url:
801
+ target = validate_repo_url(url)
802
+ if not target:
803
+ add("repo-url", False, f"inválida: {redact_url(url)}")
804
+ else:
805
+ path = state_file_for(*target)
806
+ state_for_token = load_state(path) if path.exists() else None
807
+ try:
808
+ token, source = github.resolve_token(getattr(args, "token", None),
809
+ state_for_token)
810
+ except Exception as error:
811
+ token, source = None, f"error: {error}"
812
+ add("token", bool(token),
813
+ f"source={source}" if token else
814
+ "sin token (usa --token, GITHUB_TOKEN/GH_TOKEN, --store-token o "
815
+ "'gh auth login')")
816
+
817
+ if token:
818
+ try:
819
+ status, data, _ = github.api("GET", "/rate_limit", token)
820
+ add("github-api", status == 200, f"HTTP {status}")
821
+ if status == 200:
822
+ core = (data.get("resources") or {}).get("core") or {}
823
+ add("rate-limit", True,
824
+ f"remaining={core.get('remaining')}/{core.get('limit')}",
825
+ required=False)
826
+ except SkillError as error:
827
+ add("github-api", False, error.message)
828
+ add("gh-cli", github.shutil.which("gh") is not None,
829
+ "presente (fuente opcional de token)" if github.shutil.which("gh")
830
+ else "ausente (opcional: solo fuente alternativa de token)",
831
+ required=False)
832
+ add("state-files", True,
833
+ ", ".join(path.name for path in list_state_files()) or "ninguno",
834
+ required=False)
835
+
836
+ if url:
837
+ target = validate_repo_url(url)
838
+ if target:
839
+ add("repo-url", True, f"https://github.com/{target[0]}/{target[1]}")
840
+ if token:
841
+ ok, code, detail = github.check_issues_write(target[0], target[1], token)
842
+ add("issues-write", ok, f"{code}: {detail}")
843
+
844
+ emit({"ok": all(check["ok"] for check in checks if check["required"]),
845
+ "doctor": checks})
846
+ return EXIT_OK