github-proyect-tracking 0.4.0 → 0.5.0

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/README.md CHANGED
@@ -6,11 +6,14 @@ de GitHub mediante Issues y comentarios**. Si el proceso se interrumpe (error,
6
6
  caída de sesión, reinicio), el skill reanuda exactamente donde quedó leyendo el
7
7
  último comentario del Issue.
8
8
 
9
- > **v0.4.0** — correcciones de `Review_V3.md`: resultados terminales derivados
10
- > del Issue, recuperación explícita tras `ERROR`, reconciliación por UUID ante
11
- > respuestas perdidas o 5xx, heartbeat durante toda la operación, esquema local
12
- > v2 con migración e instaladores transaccionales. El CLI se separó en módulos
13
- > sin cambiar su punto de entrada ni sus formatos visibles.
9
+ > **v0.5.0** — el Issue ahora identifica el proceso rastreado:
10
+ > `init --process-name "Mi Skill"` produce el título
11
+ > `[Process Tracking][Mi Skill] Execution ID: …` y el cuerpo incluye
12
+ > `- **Proceso:** Mi Skill` (compatibilidad total con Issues anteriores).
13
+ > Conserva las correcciones de `Review_V3.md` (v0.4.x): resultados
14
+ > terminales derivados del Issue, recuperación explícita tras `ERROR`,
15
+ > reconciliación por UUID, heartbeat, esquema v2 con migración e
16
+ > instaladores transaccionales.
14
17
 
15
18
  ---
16
19
 
@@ -145,7 +148,7 @@ npm publish # prepack empaqueta skill/ + README + LICENSE automáticament
145
148
  ```
146
149
 
147
150
  `npm pack` genera el tarball verificable de forma idéntica
148
- (`npx --yes ./npm/github-proyect-tracking-0.4.0.tgz install`).
151
+ (`npx --yes ./npm/github-proyect-tracking-0.5.0.tgz install`).
149
152
 
150
153
  ### Desinstalación
151
154
 
@@ -168,7 +171,7 @@ cp -R github-proyect-tracking/ ~/.hermes/profiles/<perfil>/skills/software-devel
168
171
  ### Ejecutar las pruebas
169
172
 
170
173
  ```bash
171
- python3 tests/test_tracking.py # 141 pruebas: 140 offline + 1 skip opt-in
174
+ python3 tests/test_tracking.py # 157 pruebas: 140 offline + 1 skip opt-in
172
175
  ```
173
176
 
174
177
  ---
@@ -358,7 +361,7 @@ Excepciones documentadas al contrato "un JSON por comando": `--help`/
358
361
 
359
362
  | Comando | Efecto | Efectos secundarios |
360
363
  |---|---|---|
361
- | `init --url URL [--plan JSON \| --plan-file F] [--token T] [--store-token]` | Crea el Issue y la memoria (plan sanitizado) | Issue nuevo en GitHub + archivo local |
364
+ | `init --url URL [--plan JSON \| --plan-file F] [--token T] [--store-token] [--process-name N]` | Crea el Issue y la memoria (plan sanitizado; `N` = skill padre, aparece en título/cuerpo del Issue) | Issue nuevo en GitHub + archivo local |
362
365
  | `resume [--url URL]` | Veredicto de reanudación | Actualiza memoria local |
363
366
  | `step-start --name N` | Comenta INICIANDO | Comentario + memoria |
364
367
  | `step-done --name N` | Comenta COMPLETADO | Comentario + memoria |
@@ -411,7 +414,7 @@ rechazan.
411
414
 
412
415
  ### 4.6 Pruebas
413
416
 
414
- La suite descubre **141 pruebas**: **140 offline** y una integración real
417
+ La suite descubre **157 pruebas**: **140 offline** y una integración real
415
418
  omitida por defecto. Las pruebas offline usan stdlib, `FakeGateway`, inyección
416
419
  de fallos, subprocesos CLI e instaladores reales en directorios temporales.
417
420
  Cubren los formatos visibles, máquina de estados, reconciliación con UUID y
@@ -420,7 +423,7 @@ contexto ambiguo, seguridad, rollback y contrato JSON.
420
423
 
421
424
  ```bash
422
425
  python3 tests/test_tracking.py
423
- # Ran 141 tests ... OK (skipped=1)
426
+ # Ran 157 tests ... OK (skipped=1)
424
427
  python3 tools/check_docs.py
425
428
  ```
426
429
 
package/bin/cli.js CHANGED
@@ -68,6 +68,22 @@ function parseArgs(argv) {
68
68
  return out;
69
69
  }
70
70
 
71
+ function mostRecentProfile(hermesHome) {
72
+ // último recurso: el perfil con actividad más reciente (Hermes no
73
+ // persiste "perfil activo" global; en su terminal no hay HERMES_*).
74
+ const dir = path.join(hermesHome, 'profiles');
75
+ try {
76
+ let best = null, bestT = -1;
77
+ for (const name of fs.readdirSync(dir)) {
78
+ const p = path.join(dir, name);
79
+ if (!fs.statSync(p).isDirectory()) continue;
80
+ const t = fs.statSync(p).mtimeMs;
81
+ if (t > bestT) { bestT = t; best = name; }
82
+ }
83
+ return best;
84
+ } catch { return null; }
85
+ }
86
+
71
87
  function profileFromAgentMd(cwd) {
72
88
  for (const f of ['AGENT.md', 'AGENTS.md']) {
73
89
  const p = path.join(cwd, f);
@@ -89,19 +105,45 @@ function resolveDest(flags) {
89
105
  let profile = flags.profile || process.env.HERMES_PROFILE
90
106
  || profileFromAgentMd(process.cwd());
91
107
  const exists = (p) => fs.existsSync(path.join(hermesHome, 'profiles', p));
92
- // E-24: --profile EXPLÍCITO inexistente → error claro, no default
108
+ const available = () => fs.readdirSync(path.join(hermesHome, 'profiles'))
109
+ .filter((n) => fs.statSync(path.join(hermesHome, 'profiles', n)).isDirectory());
110
+ // --profile EXPLÍCITO inexistente → error claro (nunca fallback silencioso)
93
111
  if (explicit && !exists(profile)) {
94
112
  die(`el perfil '${profile}' no existe en ${hermesHome}/profiles/ ` +
95
- '(usa --profile con un perfil válido o --user)', 2);
113
+ `(disponibles: ${available().join(', ') || 'ninguno'})`, 2);
96
114
  }
97
- // V2-E-11: AUTOMÁTICO inexistente → fallback a default CON aviso
98
- // (paridad con lib/common.sh)
115
+ // Automático inexistente → elegir uno sensato en vez de abortar:
116
+ // 1) 'default' si existe, 2) el ÚNICO perfil si solo hay uno.
99
117
  if (!profile || !exists(profile)) {
100
- console.error(`ADVERTENCIA: el perfil automático '${profile || "(ninguno)"}' ` +
101
- 'no existe; usando default (pasa --profile para elegir uno)');
102
- profile = 'default';
103
- if (!exists(profile)) {
104
- die(`tampoco existe el perfil 'default' en ${hermesHome}/profiles/`, 2);
118
+ const sugerido = profile;
119
+ const perfs = available();
120
+ if (exists('default')) {
121
+ if (sugerido) {
122
+ console.error(`ADVERTENCIA: el perfil automático '${sugerido}' no existe; ` +
123
+ "usando 'default'");
124
+ }
125
+ profile = 'default';
126
+ } else if (perfs.length === 1) {
127
+ profile = perfs[0];
128
+ console.error(`ADVERTENCIA: perfil automático '${sugerido || "(no detectado)"}' ` +
129
+ `no disponible; usando el único perfil existente '${profile}'`);
130
+ } else if (perfs.length > 1) {
131
+ const recent = mostRecentProfile(hermesHome);
132
+ if (recent) {
133
+ profile = recent;
134
+ console.error(`ADVERTENCIA: no se detectó perfil ` +
135
+ `(${sugerido ? `'${sugerido}' no existe` : 'sin HERMES_PROFILE ni AGENT.md'}); ` +
136
+ `usando el de actividad más reciente '${profile}'. ` +
137
+ 'Usa --profile PERFIL para elegirlo explícitamente');
138
+ } else {
139
+ die(`no se pudo determinar el perfil: ${sugerido
140
+ ? `'${sugerido}' no existe` : 'no se detectó (ejecuta desde el repo o define HERMES_PROFILE)'}. ` +
141
+ `Perfiles disponibles: ${perfs.join(', ')}. ` +
142
+ 'Usa --profile PERFIL o --dest RUTA', 2);
143
+ }
144
+ } else {
145
+ die(`no hay perfiles de Hermes en ${hermesHome}/profiles/; ` +
146
+ 'usa --dest RUTA para indicar el destino', 2);
105
147
  }
106
148
  }
107
149
  return path.join(hermesHome, 'profiles', profile, 'skills');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-proyect-tracking",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Instalador npx del skill github-proyect-tracking para Hermes Agent: rastrea el estado de procesos en GitHub Issues con reanudacion.",
5
5
  "bin": {
6
6
  "github-proyect-tracking": "bin/cli.js"
package/skill/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: github-proyect-tracking
3
3
  description: Track process execution state in GitHub Issues with resume.
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  author: Rommel (rommelmg), Hermes Agent
6
6
  license: MIT
7
7
  platforms: [linux, macos, windows]
@@ -69,7 +69,7 @@ python3 "$SKILL" doctor --url https://github.com/OWNER/REPO --token "$GITHUB_TOK
69
69
 
70
70
  | Comando | Efecto |
71
71
  |---|---|
72
- | `init --url URL [--plan JSON] [--store-token]` | Valida URL+token, crea el Issue, guarda `active_process_id` |
72
+ | `init --url URL [--plan JSON] [--store-token] [--process-name N]` | Valida URL+token, crea el Issue (título `[Process Tracking][N] Execution ID: …` si se da N), guarda `active_process_id` y `process_name` |
73
73
  | `resume [--url URL]` | Lee el ÚLTIMO comentario y dicta cómo continuar |
74
74
  | `step-start --name N` | Comenta `[STATUS: INICIANDO] - Paso: N` (valida orden del plan) |
75
75
  | `step-done --name N` | Comenta `[STATUS: COMPLETADO] - Paso: N` (exige paso en vuelo) |
@@ -95,9 +95,14 @@ exit 0) y `KeyboardInterrupt` (130).
95
95
  lectura hace fallar `issues-write`; `UNVERIFIED` se reserva para permisos
96
96
  granulares que la API no puede confirmar.
97
97
  2. **Init.** `init --url URL --plan '["Paso 1: ...", ...]'` (plan
98
- recomendado: nombres ÚNICOS; hace la reanudación determinista). Si
99
- devuelve `RESUME_REQUIRED` (exit 7), NO crees otro Issue: ve al paso 3.
100
- Criterio: `execution_id` e `issue_url` en la salida.
98
+ recomendado: nombres ÚNICOS; hace la reanudación determinista).
99
+ Si el proceso pertenece a un skill padre, añade
100
+ `--process-name "Mi Skill"`: el título del Issue pasa a
101
+ `[Process Tracking][Mi Skill] Execution ID: …` y el cuerpo incluye
102
+ `- **Proceso:** Mi Skill` — así el Issue identifica QUÉ se rastrea.
103
+ Si devuelve `RESUME_REQUIRED` (exit 7), NO crees otro Issue: ve al
104
+ paso 3. Criterio: `execution_id`, `process_name` e `issue_url` en la
105
+ salida.
101
106
  3. **Reanudar (si aplica).** `resume` y sigue el `verdict.action`
102
107
  (`RERUN_STEP`, `NEXT_STEP`, `START_FIRST`, `ALL_STEPS_DONE`/
103
108
  `FINISH_CLEANUP` → finalize, `ABORTED_EXTERNALLY` → pregunta al
@@ -42,7 +42,7 @@ texto y los usa para reconciliar únicamente el intento que originó el POST.
42
42
 
43
43
  ## Comandos
44
44
 
45
- ### `init --url URL [--plan JSON | --plan-file F] [--token T] [--prompt-token] [--store-token]`
45
+ ### `init --url URL [--plan JSON | --plan-file F] [--token T] [--prompt-token] [--store-token] [--process-name N]`
46
46
 
47
47
  - Valida la URL (regex, redactada en errores), resuelve el token, valida
48
48
  `issues:write` ANTES de crear nada.
@@ -54,6 +54,12 @@ texto y los usa para reconciliar únicamente el intento que originó el POST.
54
54
  `resume` determinista.
55
55
  - `--store-token`: persistencia del token OPT-IN (por defecto NO se
56
56
  guarda; `--no-store-token` quedó obsoleto).
57
+ - `--process-name N`: nombre del proceso/skill padre. Título resultante:
58
+ `[Process Tracking][N] Execution ID: {exec_id}`; el cuerpo incluye
59
+ `- **Proceso:** N`; estado y JSON llevan `process_name`. Sin `N`, se
60
+ conserva el formato legado `[Process Tracking] Execution ID: …`.
61
+ Un secreto en `N` se redacta; `[`/`]`/saltos → `VALIDATION_ERROR`.
62
+ - Salida de `init`: añade `process_name` e `issue_title`.
57
63
 
58
64
  Salida: `execution_id`, `issue_number`, `issue_url`, `state_file`, `plan`,
59
65
  `first_step`, `token_source`.
@@ -95,9 +95,12 @@ def build_parser():
95
95
  "init", help="nueva ejecución: valida, crea el Issue y guarda memoria")
96
96
  common(subparser, url_required=True)
97
97
  subparser.add_argument("--plan", default=None, metavar="JSON",
98
- help='plan de pasos con nombres ÚNICOS, ej. \'["Paso 1","Paso 2"]\'')
98
+ help='plan de pasos con nombres ÚNICOS, ej. \'["Paso 1", "Paso 2"]\'')
99
99
  subparser.add_argument("--plan-file", default=None, metavar="F",
100
100
  help="archivo JSON con la lista de pasos")
101
+ subparser.add_argument("--process-name", default=None, metavar="N",
102
+ help="nombre del proceso/skill padre que se rastrea "
103
+ "(aparece en el título y cuerpo del Issue)")
101
104
  subparser.add_argument("--prompt-token", action="store_true",
102
105
  help="pedir el token interactivamente (TTY)")
103
106
  subparser.add_argument("--store-token", action="store_true",
@@ -153,6 +156,9 @@ def build_parser():
153
156
  common(subparser, url_required=True)
154
157
  subparser.add_argument("--execution-id", required=True, metavar="ID",
155
158
  help="Execution ID EXACTO del título del Issue")
159
+ subparser.add_argument("--process-name", default=None, metavar="N",
160
+ help="nombre del proceso a adoptar (filtro adicional "
161
+ "opcional si hay ambigüedad)")
156
162
  subparser.add_argument("--force", action="store_true",
157
163
  help="reemplazar un proceso activo existente (conserva token "
158
164
  "e historial)")
@@ -14,8 +14,9 @@ from .common import (
14
14
  EXIT_RESUME_REQUIRED, EXIT_RUNTIME, EXIT_TOKEN_INSUFFICIENT,
15
15
  EXIT_VALIDATION, MAX_DETAILS_LEN, MAX_PLAN_STEPS, SCHEMA_VERSION,
16
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,
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,
19
20
  )
20
21
  from .events import (
21
22
  build_issue_body, events_from_comments, fmt_cancelado, fmt_completado,
@@ -125,6 +126,7 @@ def cmd_init(args):
125
126
  owner, repo = target
126
127
  path = state_file_for(owner, repo)
127
128
  plan = load_plan(args)
129
+ process_name = normalize_process_name(getattr(args, "process_name", None))
128
130
 
129
131
  lock = StateLock(path)
130
132
  with lock:
@@ -154,8 +156,9 @@ def cmd_init(args):
154
156
  store = bool(args.store_token) and source in ("cli", "prompt")
155
157
 
156
158
  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
+ title = issue_title(execution_id, process_name)
160
+ body = build_issue_body(args.url.strip(), execution_id, plan,
161
+ process_name=process_name)
159
162
  recovered = False
160
163
  create_error = None
161
164
  lock.ensure_owner()
@@ -202,6 +205,7 @@ def cmd_init(args):
202
205
  "owner": owner, "repo": repo,
203
206
  "active_process_id": number,
204
207
  "execution_id": execution_id,
208
+ "process_name": process_name,
205
209
  "issue_url": data.get("html_url"),
206
210
  "process_state": "RUNNING",
207
211
  "current_step": None,
@@ -224,6 +228,7 @@ def cmd_init(args):
224
228
  "ok": True, "action": "INIT",
225
229
  "execution_id": execution_id, "issue_number": number,
226
230
  "issue_url": data.get("html_url"), "state_file": str(path),
231
+ "process_name": process_name, "issue_title": title,
227
232
  "plan": plan, "first_step": plan[0] if plan else None,
228
233
  "recovered": recovered,
229
234
  "token_source": source, "token_stored": bool(state["github_token"]),
@@ -653,8 +658,9 @@ def cmd_adopt(args):
653
658
  existing = load_state(path)
654
659
  token, _ = need_token(args, existing)
655
660
 
656
- query = (f'repo:{owner}/{repo} "[Process Tracking] Execution ID: {execution_id}" '
657
- f'in:title is:issue')
661
+ # v0.5.0: el título puede incluir [Nombre]; se busca por el execution_id
662
+ # (único) y el filtrado canónico exacto lo hace canonical_title_match.
663
+ query = f'repo:{owner}/{repo} "{execution_id}" in:title is:issue'
658
664
  status, data, _ = github.api(
659
665
  "GET", "/search/issues?" + urllib.parse.urlencode({"q": query, "per_page": 30}),
660
666
  token)
@@ -662,8 +668,10 @@ def cmd_adopt(args):
662
668
  raise SkillError(EXIT_RUNTIME, "SEARCH_FAILED",
663
669
  f"búsqueda de Issues devolvió {status}: "
664
670
  f"{data.get('message', '')}")
671
+ process_name = normalize_process_name(getattr(args, "process_name", None))
665
672
  matches = [issue for issue in (data.get("items") or [])
666
- if github.canonical_title_match(issue, execution_id)]
673
+ if github.canonical_title_match(issue, execution_id,
674
+ process_name=process_name)]
667
675
  if not matches:
668
676
  raise SkillError(EXIT_NOT_FOUND, "ISSUE_NOT_FOUND",
669
677
  f"no se encontró un Issue con título canónico para "
@@ -691,6 +699,10 @@ def cmd_adopt(args):
691
699
  plan = parse_plan_from_body(issue.get("body") or "")
692
700
  verdict = plan_resume(events, plan, issue.get("state", "open"))
693
701
  action = verdict["action"]
702
+ title_match = TITLE_RE.match((issue.get("title") or "").strip())
703
+ adopted_name = (title_match.group("process_name")
704
+ if title_match and title_match.group("process_name")
705
+ else None)
694
706
  if issue.get("state") == "open":
695
707
  active = number
696
708
  process_state = ("ERROR" if events and events[-1]["status"] == STATUS_ERROR
@@ -708,6 +720,7 @@ def cmd_adopt(args):
708
720
  "owner": owner, "repo": repo,
709
721
  "active_process_id": active,
710
722
  "execution_id": execution_id,
723
+ "process_name": process_name or adopted_name,
711
724
  "issue_url": issue.get("html_url"),
712
725
  "process_state": process_state,
713
726
  "current_step": verdict.get("resume_from_step"),
@@ -727,7 +740,9 @@ def cmd_adopt(args):
727
740
  "ok": True, "action": "ADOPT", "execution_id": execution_id,
728
741
  "issue_number": number, "issue_url": issue.get("html_url"),
729
742
  "issue_state": issue.get("state"), "verdict": verdict,
730
- "process_state": process_state, "state_file": str(path),
743
+ "process_state": process_state,
744
+ "process_name": process_name or adopted_name,
745
+ "state_file": str(path),
731
746
  })
732
747
  return EXIT_OK
733
748
 
@@ -7,7 +7,7 @@ import re
7
7
  import uuid
8
8
  from datetime import datetime, timezone
9
9
 
10
- VERSION = "0.4.0"
10
+ VERSION = "0.5.0"
11
11
  API_BASE = "https://api.github.com"
12
12
  USER_AGENT = f"github-proyect-tracking/{VERSION}"
13
13
 
@@ -47,10 +47,18 @@ REPO_URL_RE = re.compile(
47
47
  r"(?P<repo>[A-Za-z0-9._-]+?)(?:\.git)?/?$",
48
48
  re.IGNORECASE,
49
49
  )
50
+ # Título dual (v0.5.0): con o sin nombre de proceso.
51
+ # [Process Tracking][Mi Skill] Execution ID: exec-...
52
+ # [Process Tracking] Execution ID: exec-... (formato <=0.4.x)
50
53
  TITLE_RE = re.compile(
51
- r"^\[Process Tracking\]\s*(?:-\s*)?Execution ID:\s*(?P<exec_id>\S+?)\s*$",
54
+ r"^\[Process Tracking\]"
55
+ r"(?:\[(?P<process_name>"
56
+ r"(?:[^\[\]\r\n]|\[RED[Aa]ctado:[^\[\]]*\]){1,200}"
57
+ r")\])?"
58
+ r"\s*(?:-\s*)?Execution ID:\s*(?P<exec_id>\S+?)\s*$",
52
59
  re.IGNORECASE,
53
60
  )
61
+ MAX_PROCESS_NAME_LEN = 100
54
62
 
55
63
  SECRET_REDACTIONS = [
56
64
  (re.compile(r"(?i)\b(authorization|proxy-authorization)\s*[:=]\s*"
@@ -68,6 +76,43 @@ SECRET_REDACTIONS = [
68
76
  ]
69
77
 
70
78
 
79
+ def normalize_process_name(name):
80
+ """Normaliza el nombre del proceso (skill padre) para título y cuerpo
81
+ del Issue. Orden V2-aprendido: validar la entrada CRUDA (longitud y
82
+ corchetes) ANTES de sanitizar — los marcadores [REDactado:...] que
83
+ introduce la sanitización son legítimos y TITLE_RE los acepta."""
84
+ if name is None:
85
+ return None
86
+ text = str(name).strip()
87
+ if not text:
88
+ return None
89
+ if len(text) > MAX_PROCESS_NAME_LEN:
90
+ raise SkillError(
91
+ EXIT_VALIDATION, "VALIDATION_ERROR",
92
+ f"el nombre del proceso no puede exceder {MAX_PROCESS_NAME_LEN} "
93
+ f"caracteres (recibido: {len(text)})")
94
+ if any(ch in text for ch in "[]\r\n"):
95
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
96
+ "el nombre del proceso no puede contener '[', ']' "
97
+ "ni saltos de línea (rompe el título canónico)")
98
+ clean, _ = sanitize_text(text)
99
+ clean = clean.strip()
100
+ if not clean:
101
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
102
+ "el nombre del proceso quedó vacío tras sanitizar")
103
+ return clean
104
+
105
+
106
+ def issue_title(execution_id, process_name=None):
107
+ """Título canónico del Issue con el nombre del proceso (v0.5.0).
108
+ El nombre debe venir YA normalizado (normalize_process_name); aquí no
109
+ se re-valida porque los marcadores [REDactado:...] son legítimos."""
110
+ if process_name:
111
+ return f"[Process Tracking][{process_name}] Execution ID: {execution_id}"
112
+ return f"[Process Tracking] Execution ID: {execution_id}"
113
+
114
+
115
+
71
116
  class SkillError(Exception):
72
117
  """Error operativo que se serializa como un único objeto JSON."""
73
118
 
@@ -98,11 +98,15 @@ def events_from_comments(comments):
98
98
  return events
99
99
 
100
100
 
101
- def build_issue_body(url, execution_id, plan):
101
+ def build_issue_body(url, execution_id, plan, process_name=None):
102
102
  lines = [
103
103
  "Proceso rastreado por el skill `github-proyect-tracking`.",
104
104
  "",
105
105
  f"- **Execution ID:** `{execution_id}`",
106
+ ]
107
+ if process_name:
108
+ lines.append(f"- **Proceso:** {process_name}")
109
+ lines += [
106
110
  f"- **Proyecto:** {url}",
107
111
  f"- **Iniciado (UTC):** {now_iso()}",
108
112
  "",
@@ -14,7 +14,7 @@ import uuid
14
14
  from .common import (
15
15
  AMBIGUOUS_HTTP_STATUSES, API_BASE, EXIT_NOT_FOUND, EXIT_NO_TOKEN,
16
16
  EXIT_RUNTIME, EXIT_TOKEN_INSUFFICIENT, MAX_COMMENT_PAGES, TITLE_RE,
17
- USER_AGENT, SkillError,
17
+ USER_AGENT, SkillError, issue_title,
18
18
  )
19
19
  from .events import events_from_comments, transition_metadata, with_transition_metadata
20
20
 
@@ -295,6 +295,7 @@ def close_issue(owner, repo, number, token):
295
295
 
296
296
 
297
297
  def find_issue_by_title(owner, repo, title, token):
298
+ # v0.5.0: title ya viene construido con issue_title()
298
299
  url = (f"{API_BASE}/repos/{owner}/{repo}/issues?state=all&sort=created"
299
300
  "&direction=desc&per_page=100")
300
301
  seen = set()
@@ -310,6 +311,14 @@ def find_issue_by_title(owner, repo, title, token):
310
311
  return None
311
312
 
312
313
 
313
- def canonical_title_match(issue, execution_id):
314
+ def canonical_title_match(issue, execution_id, process_name=None):
315
+ """v0.5.0: casa el título canónico con o sin nombre de proceso; el
316
+ exec_id debe coincidir EXACTO y, si se indica process_name, también."""
314
317
  match = TITLE_RE.match((issue.get("title") or "").strip())
315
- return bool(match and match.group("exec_id").lower() == execution_id.lower())
318
+ if not match or match.group("exec_id").lower() != execution_id.lower():
319
+ return False
320
+ if process_name:
321
+ found = match.group("process_name")
322
+ if (found or "").strip().lower() != str(process_name).strip().lower():
323
+ return False
324
+ return True