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