@trycore/spec-build-harness 0.8.5 → 0.11.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.
Files changed (107) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +27 -4
  3. package/INSTALL.md +27 -5
  4. package/METODOLOGIA.md +55 -5
  5. package/README.md +39 -6
  6. package/VERSION +1 -1
  7. package/agents/build/build-orchestrator.md +33 -7
  8. package/agents/build/dor-dod-gatekeeper.md +13 -5
  9. package/agents/build/wiring-adversarial-verifier.md +52 -5
  10. package/commands/build/architect.md +1 -1
  11. package/commands/build/claim.md +46 -0
  12. package/commands/build/escalate.md +36 -0
  13. package/commands/build/front.md +9 -3
  14. package/commands/build/onboard.md +75 -14
  15. package/commands/build/prototype.md +3 -2
  16. package/commands/build/reflect.md +60 -40
  17. package/commands/build/release.md +10 -7
  18. package/commands/build/resume.md +33 -13
  19. package/commands/build/slice.md +32 -27
  20. package/commands/build/status.md +35 -0
  21. package/commands/build/work.md +11 -8
  22. package/config/build-config.template.json +4 -0
  23. package/dist/cli.js +32 -0
  24. package/dist/commands/doctor.js +42 -0
  25. package/dist/commands/init.js +84 -1
  26. package/dist/commands/migrate.js +153 -0
  27. package/dist/commands/status.js +34 -0
  28. package/dist/lib/normalize.js +1123 -0
  29. package/dist/lib/paths.js +6 -0
  30. package/dist/lib/runtime-client.js +196 -0
  31. package/dist/lib/settings-merge.js +3 -3
  32. package/dist/lib/state-bundle.js +150 -0
  33. package/docs/commands.md +25 -8
  34. package/docs/getting-started.md +1 -0
  35. package/docs/hooks.md +114 -27
  36. package/docs/runtime/guia-modo-dual-y-migracion.md +143 -0
  37. package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
  38. package/docs/runtime/protocolo-cliente-runtime.md +120 -35
  39. package/hooks/build/build-gate-check.sh +21 -0
  40. package/hooks/build/context-monitor.sh +82 -15
  41. package/hooks/build/context-sync.sh +192 -0
  42. package/hooks/build/design-source-guard.sh +30 -2
  43. package/hooks/build/dual-compare.sh +92 -0
  44. package/hooks/build/event-emitter.sh +32 -0
  45. package/hooks/build/gitflow-guard.sh +164 -14
  46. package/hooks/build/heartbeat.sh +259 -0
  47. package/hooks/build/lib/agent-context.sh +139 -0
  48. package/hooks/build/lib/config.sh +27 -0
  49. package/hooks/build/lib/projection.sh +71 -0
  50. package/hooks/build/lib/runtime-client.sh +625 -0
  51. package/hooks/build/lib/runtime-ops.sh +227 -0
  52. package/hooks/build/lib/state-io.sh +5 -18
  53. package/hooks/build/load-build-state.sh +64 -2
  54. package/hooks/build/reflect-nudge.sh +15 -0
  55. package/hooks/build/release-gate-nudge.sh +15 -0
  56. package/hooks/build/release-ops.sh +171 -0
  57. package/hooks/build/scaffold-guard.sh +29 -2
  58. package/hooks/build/session-start.sh +103 -0
  59. package/hooks/build/session-stop.sh +22 -0
  60. package/hooks/build/slice-ops.sh +948 -0
  61. package/hooks/build/stack-guard.sh +8 -0
  62. package/hooks/build/statusline-bridge.sh +24 -3
  63. package/hooks/build-harness.json +16 -0
  64. package/package.json +3 -3
  65. package/scripts/check-agnostic.sh +3 -1
  66. package/scripts/check-pack-clean.sh +31 -0
  67. package/scripts/check-runtime-purity.sh +43 -0
  68. package/scripts/denylist.txt +4 -0
  69. package/scripts/lib/front-plan.py +4 -0
  70. package/scripts/lib/graph-bundle.py +181 -0
  71. package/scripts/runtime-purity-allow.txt +5 -0
  72. package/scripts/smoke-test.sh +1 -1
  73. package/scripts/tests/lib/http-stub.py +46 -0
  74. package/scripts/tests/test-baseline-verdict.sh +92 -0
  75. package/scripts/tests/test-config.sh +25 -0
  76. package/scripts/tests/test-hooks-runtime.sh +828 -0
  77. package/scripts/tests/test-install.sh +103 -0
  78. package/scripts/tests/test-runtime-client.sh +298 -0
  79. package/scripts/tests/test-schema.sh +29 -1
  80. package/scripts/tests/test-skill-ops.sh +1367 -0
  81. package/skills/building-a-micro-change/SKILL.md +22 -4
  82. package/skills/building-a-slice/SKILL.md +55 -21
  83. package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
  84. package/skills/building-a-slice/references/dod.md +12 -3
  85. package/skills/building-a-slice/references/dor.md +3 -2
  86. package/skills/building-a-slice/references/evidence-budget.md +51 -0
  87. package/skills/building-a-slice/references/exploration-fanout.md +1 -1
  88. package/skills/building-a-slice/references/gitflow.md +1 -1
  89. package/skills/building-a-slice/references/regression-baseline.md +67 -0
  90. package/skills/building-a-slice/references/runtime-protocol.md +75 -0
  91. package/skills/building-a-slice/references/state-protocol.md +12 -1
  92. package/skills/building-a-slice/workflows/README.md +7 -3
  93. package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
  94. package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
  95. package/skills/managing-parallel-front/SKILL.md +32 -16
  96. package/skills/openspec-archive-change/SKILL.md +15 -0
  97. package/skills/prototyping-screens/SKILL.md +9 -5
  98. package/skills/releasing-a-version/SKILL.md +26 -16
  99. package/skills/releasing-a-version/references/release-dod.md +7 -5
  100. package/skills/releasing-a-version/workflows/README.md +2 -1
  101. package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
  102. package/skills/setup-architecture/SKILL.md +4 -2
  103. package/state/README.md +16 -1
  104. package/state/build-state.schema.json +2 -1
  105. package/templates/CLAUDE.md.template +16 -0
  106. package/templates/settings-hooks.template.json +8 -4
  107. package/internal/skills/auditar-arnes/SKILL.md +0 -29
@@ -1,43 +1,74 @@
1
1
  # Protocolo cliente — Harness v0.9 ↔ Agent Orchestrator Runtime
2
2
 
3
+ > **Estado de implementación:** todo el documento refleja lo construido en `hooks/build/` y `src/` (EP-OR-08 sub-slices A-E, código completo en `main`; el corte a `runtime` como único camino sigue pendiente de un piloto real — ver `plan-migracion-harness-v0.9.md` §2.3 y la guía práctica `guia-modo-dual-y-migracion.md`). Las rutas marcadas «contrato del cliente» esperan confirmación contra el OpenAPI real en el re-volcado de EP-OR-13; viven en un solo sitio del cliente bash (`hooks/build/lib/runtime-ops.sh`) y su espejo TS (`src/lib/runtime-client.ts`).
4
+
3
5
  > Contrato del lado cliente. El contrato del lado servidor vive en trycore-ia-hub: `docs/01-prd/anexos/orchestrator-runtime-design.md` (§4 catálogo de eventos, §5 API). Este documento define cómo el harness lo consume: autenticación, mapeo hook→endpoint, sincronización de contexto, cola offline y declaración de tipos de asset.
4
6
 
5
7
  ## 1. Autenticación y arranque
6
8
 
7
- - **Token de proyecto:** emitido por un ADMIN en el hub, entregado al desarrollador, configurado una vez con `trycore-build init` (se guarda en `.claude/state/runtime.credentials`, fuera de git, `0600`). Todas las llamadas: `Authorization: Bearer <token>`.
8
- - **Registro:** `POST /agents/register {harness_version, capabilities}` → `{agent_id, project, manifest_hash, poll_interval_s, lease_ttl_s}`. Los intervalos los dicta el servidor (el cliente no los hardcodea). `409 incompatible_version` ⇒ mensaje claro con la versión mínima requerida.
9
- - **Identidad local:** `agent_id` persiste en `.claude/state/runtime.credentials`; un mismo clon re-registrado reactiva su agente (no crea otro).
9
+ - **Token de proyecto:** emitido por un ADMIN en el hub, entregado al desarrollador, configurado una vez con `trycore-build init --runtime-url --runtime-token` (se guarda en `.claude/state/runtime.credentials`, `0600`, sembrado en `.gitignore` por `init`). Todas las llamadas: `Authorization: Bearer <token>`.
10
+ - **Registro:** `POST /agents/register {harness_version, asset_types}` → el servidor responde `{agent_key, project_id, harness_version, poll_interval_s, lease_ttl_s, manifest_hash, catalog{…}}`; el cliente (`runtime_register`) persiste hoy solo `agent_key`, `project_id`, `poll_interval_s`, `lease_ttl_s` y `manifest_hash` — `catalog` (tipos de asset) todavía no se persiste (el cliente ya envía `asset_types` desde `.claude/asset-types.json`, pero no consume el `catalog` de vuelta), `harness_version` no se re-persiste (ya se conoce localmente). Los intervalos los dicta el servidor (el cliente no los hardcodea). `409 incompatible_version` ⇒ mensaje claro con la versión mínima requerida (cuerpo `{error, min_version}`).
11
+ - **Identidad local:** `agent_key`/`project_id` persisten en `.claude/state/runtime.credentials` (`0600`, protegido en `.gitignore` desde el primer `init`); un mismo clon re-registrado reactiva su agente (no crea otro): `runtime_register` hace upsert sobre el fichero existente, nunca lo recrea desde cero.
10
12
 
11
13
  ## 2. Mapeo hook → endpoint (la telemetría es del harness, no del modelo)
12
14
 
13
15
  | Evento Claude Code | Hook v0.9 | Llamada | Notas |
14
16
  |---|---|---|---|
15
- | SessionStart (`startup\|clear\|compact`) | `session-start.sh` | `POST /agents/register` (idempotente) + `GET /agent/context` + sync de contexto (§4) | Inyecta `additionalContext`: slice activo, fase, gates abiertos, wiring failing, resume_hint, nudges, versión de contexto. |
16
- | PostToolUse (`Edit\|Write\|MultiEdit\|Bash\|Task`) | `event-emitter.sh` | encola `POST /events` (lote) | **Nunca síncrono en el camino del tool.** Encola en `.claude/state/outbox/` y despacha en background (ver §5). |
17
- | Timer / PostToolUse throttled | `heartbeat.sh` | `PUT /leases/renew` | Intervalo = `lease_ttl_s / 3` (valor del servidor). |
18
- | PreCompact / Stop con contexto crítico | `context-monitor.sh` | evento `handoff_recorded {stopped_at, resume_hint}` | Además del aviso local existente. |
19
- | Stop | `session-stop.sh` | flush de outbox + `GET /nudges` (cacheado) | Renderiza nudges del servidor; sin aritmética local. |
20
- | PreToolUse (guards) | `gitflow/stack/scaffold/design-source-guard.sh` | **ninguna** (prohibido red en PreToolUse) | Leen archivos sincronizados + caché de proyección local. |
21
-
22
- Eventos emitidos por skills (no por hooks): `POST /checkpoints` (tras cada commit significativo), `POST /slices/{id}/verdicts` (al recibir el veredicto de un agente revisor), `POST /slices/{id}/submit` (entrega), `GET /tasks/next` (claim).
17
+ | SessionStart (`startup\|clear\|compact`) | `session-start.sh` | `POST /agents/register` (idempotente, con `asset_types`) + `GET /agent/context` + `context-sync.sh` (§4) + aseguramiento del daemon | Escribe la **caché de proyección normalizada** (`.claude/state/runtime-projection.json`). No inyecta contexto: el render lo hace `load-build-state.sh`, que corre después en el mismo evento. |
18
+ | SessionStart (mismo matcher) | `load-build-state.sh` | **ninguna** | Renderiza `additionalContext` desde la caché en modo `runtime`; en `legacy\|dual` sigue leyendo `build-state.json`. |
19
+ | PostToolUse (`Bash\|Edit\|Write\|MultiEdit\|Task`) | `event-emitter.sh` | **ninguna** | Solo hace la comprobación barata del daemon (stat del pidfile). Desde el issue #44 **no encola nada**: `tool_use_recorded` no existe en el catálogo v2 del hub y era ruido garantizado en `rejected/`; la telemetría de uso queda suspendida hasta que el equipo del hub decida añadir el tipo. |
20
+ | (daemon, no hook) | `heartbeat.sh --daemon` | `PUT /leases/renew` + `POST /events` | Singleton por repo con registro de ppids de sesión; late cada `lease_ttl_s/3` (valor del servidor) y despacha la cola mientras viva. Ver §9. |
21
+ | (invocado, no registrado) | `context-sync.sh` | `GET …/context/manifest`, `GET …/context/files?path=…&sha256=…`, `POST /context/synced` | Lo llama `session-start.sh` y, desde el sub-slice C, las skills antes de reclamar. |
22
+ | PreCompact / PostToolUse / Stop | `context-monitor.sh` | encola `handoff_recorded {note, resume_hint, stopped_at}` | En `runtime` el handoff **solo** es evento (no se escribe el fichero); en `dual` se escribe el fichero **y** se emite; en `legacy`, solo el fichero. `handoff_recorded` es de ámbito **slice**: solo se encola con `slice_id` conocido (misma guardia que `event-emitter.sh`); en `dual`, como no hay claim vía runtime, `active_slice` normalmente no llega por `/agent/context` y el evento no se emite (solo queda el fichero). |
23
+ | Stop | `build-gate-check.sh`, `reflect-nudge.sh`, `release-gate-nudge.sh` | **ninguna** | Renderizan los `nudges[]` que trajo `GET /agent/context` (no existe endpoint `/nudges` para agentes: los nudges viajan en el contexto de agente). En `legacy\|dual` conservan su aritmética local. |
24
+ | Stop | `session-stop.sh` | **ninguna** | Deja `outbox/.flush-request` y asegura el daemon: el flush lo ejecuta el daemon, no el hook. |
25
+ | PreToolUse (guards) | `gitflow/stack/scaffold/design-source-guard.sh` | **ninguna** (prohibido red en PreToolUse) | Leen los assets sincronizados y la caché de proyección; bloquean offline y marcan `⚠ stale`. `gitflow-guard` resuelve el repo del **comando** (`git -C`, `cd … &&`), no el de la sesión. |
26
+
27
+ Eventos emitidos por skills (no por hooks) — sub-slice C: `POST /checkpoints`, `POST /slices/{id}/verdicts`, `POST /slices/{id}/submit`, `POST /tasks/next` (claim: **siempre POST**, el GET no acepta el reporte de hashes).
23
28
 
24
29
  ## 3. Claim (el corazón del pull)
25
30
 
26
31
  ```
27
- GET /tasks/next
28
- 200 {slice_id, epic, phase, next_step, branch_base, acceptance_refs,
29
- openspec_change, lease: {ttl_s, expires_at}, manifest_hash,
30
- checkpoint: {branch, commit_sha} | null}
32
+ POST /tasks/next {context_hashes: {"<path>": "<sha256>", …}} // dict[str,str]; {} = reporta vacío;
33
+ // valor "" = fichero ilegible (nunca null)
34
+ 200 {slice_id, epic_code, epic_title, stories[], files_scope, docs_ref, phase, gates,
35
+ branch_base, openspec_change, manifest_hash,
36
+ lease: {ttl_s, expires_at}, checkpoint: {branch, commit_sha} | null}
31
37
  204 → sin trabajo disponible (la skill lo comunica y termina limpio)
32
- 409 → carrera perdida (otro agente reclamó primero): reintentar una vez
38
+ 409 → se distingue por el CUERPO, no por el status (`reason`):
39
+ drift | contention → re-sincronizar contexto y reintentar UNA vez
40
+ context_unresolvable → no reintentar; se resuelve en el hub
41
+ cross_project → no reintentar; queda auditado en el servidor
33
42
  ```
34
43
 
35
- Reglas del cliente:
36
-
37
- 1. Antes de trabajar: verificar `manifest_hash` contra el lock (§4); sincronizar si difiere. **No se abre trabajo con contexto viejo.**
38
- 2. Si `checkpoint != null`: hacer checkout/pull de la rama y **continuar desde el checkpoint, jamás reiniciar** (recuperación de otro agente caído).
39
- 3. En el claim se reportan los sha256 locales efectivos de los archivos gobernados (allowlist, policy, rules) — el servidor detecta drift/manipulación local.
40
- 4. Un agente mantiene **un solo** slice activo; `GET /tasks/next` con lease vigente devuelve el mismo slice (idempotencia).
44
+ **Siempre POST**: el `GET /tasks/next` no acepta el reporte de hashes (regla 3), así que el
45
+ cliente no lo usa para reclamar.
46
+
47
+ Reglas del cliente (las implementa `slice-ops.sh claim`, no la prosa de la skill):
48
+
49
+ 1. Antes de trabajar: `context-sync.sh <manifest_hash>` (§4). **No se abre trabajo con contexto viejo.**
50
+ 2. Si `checkpoint != null`: checkout de esa rama y **continuar desde el checkpoint, jamás reiniciar**
51
+ (recuperación de otro agente caído).
52
+ 3. En el claim se reportan los **sha256 locales efectivos** de los archivos gobernados del lock —
53
+ se hashea el fichero real, nunca se repite el sha del lock: repetirlo haría el drift indetectable.
54
+ 4. Un agente mantiene **un solo** slice activo; reclamar con lease vigente devuelve el mismo slice.
55
+ 5. Modo `dual`: **no se reclama** (el fichero decide el slice, spec §6.1) — el subcomando devuelve
56
+ rc 3. Modo `legacy`: no hay red, rc 3.
57
+ 6. **No existe claim dirigido**: `TasksNextIn` no acepta `epic_code` (pydantic ignoraría el campo y
58
+ la cola repartiría otra épica — incidente del primer piloto, issue #39). `claim --epic` falla explícito
59
+ (rc 2, sin abrir socket) explicando el protocolo real: terminar el slice en dual → cutover
60
+ admin → reclamar del hub lo que la cola reparta.
61
+
62
+ Actos de dominio posteriores (todos por `slice-ops.sh`, ninguno a mano):
63
+ `POST /slices/{id}/verdicts` · `POST /checkpoints` · `POST /slices/{id}/submit` · eventos
64
+ `wiring_*`, `progress_noted`, `slice_archived`, `slice_escalated` y los hechos de
65
+ proyecto **máquina** (`project_kind`, `harness_phase`, `foundation`) como
66
+ `project_fact_updated {fact, value, source: "AGENT"}` — el único tipo del catálogo v2 para hechos
67
+ (issue #38). Los hechos **humanos** (`scaffold_confirmed`, `design_source_*`) NO viajan por la
68
+ superficie de agente: se fijan tras el PDP por `PATCH /orchestrator/projects/{project_id}`
69
+ (consola admin); `slice-ops fact` los rechaza en runtime/dual con rc 6. Outer loop, por
70
+ `release-ops.sh`: `POST /releases/{line}/verdicts` y
71
+ `POST /fronts/{front_id}/members/{agent_key}/integration`.
41
72
 
42
73
  ## 4. Sincronización de contexto (content-addressed)
43
74
 
@@ -51,22 +82,24 @@ Lockfile: `.claude/state/context.lock`
51
82
  }
52
83
  ```
53
84
 
54
- Algoritmo (`context-sync.sh`, corre en SessionStart y pre-claim):
85
+ Algoritmo (`context-sync.sh`; lo invoca `session-start.sh` y, en el sub-slice C, las skills pre-claim):
55
86
 
56
- 1. Comparar `manifest_hash` esperado (del registro o del claim) con el del lock. Igual ⇒ fin (costo: comparación de strings).
57
- 2. Distinto ⇒ `GET /projects/{id}/context/manifest`; diff por `sha256` archivo a archivo; `GET /context/files/{path}?sha256=…` **solo** de los cambiados (ETag inmutable cacheable).
58
- 3. Escritura atómica (`mktemp` + `os.replace`, patrón ya probado en v0.8.1) sobre los destinos gobernados: `config/`, `rules/`, bloques marcados de `CLAUDE.md` (vía `markers.ts`), `docs-cache/` para `DOC`.
59
- 4. Actualizar el lock; emitir `context_synced {from_hash, to_hash, files_changed[]}`; aviso de una línea al usuario (`⬆ contexto v13 v14: allowlist (+2), PRD §4`).
87
+ 1. Comparar el `manifest_hash` esperado (de `/agent/context`, o el persistido en `runtime.credentials`) con el del lock. Igual ⇒ fin (costo: comparación de strings).
88
+ 2. Distinto ⇒ `GET /projects/{id}/context/manifest`; diff por `sha256` archivo a archivo; `GET /projects/{id}/context/files?path=…&sha256=…` **solo** de los cambiados (query params, no path params).
89
+ 3. **Destinos gobernados por este canal:** `config/`, `rules/` y `docs-cache/`, siempre bajo `.claude/`. Cualquier entrada absoluta, con `..` o fuera de esos prefijos se **rechaza sin escribir** y se lista como «no aplicada» — incluidos los bloques marcados de `CLAUDE.md`, que necesitarían integrar `markers.ts` y **aún no está implementado**. Escritura atómica (`mkstemp` + `os.replace`).
90
+ 4. El lock **solo** sella el hash nuevo si se aplicaron TODOS los archivos gobernados del plan; si la convergencia fue parcial, el lock se queda en el hash anterior y la próxima sesión reintenta. Después: `POST /context/synced {from_hash, to_hash, files_changed[]}` (endpoint propio, la única señal sin `slice_id`; `422` manifiesto desconocido, no se reintenta) y aviso de una línea al usuario.
60
91
  5. **Prohibido** editar a mano archivos gobernados: el sync los sobreescribe y el drift se reporta. El cambio legítimo se hace en el hub (propuesta→publicación).
61
92
 
62
- Offline: sin runtime, se trabaja con el último lock; la statusline marca `⚠ stale`; al reconectar, sync antes del siguiente claim.
93
+ Offline: sin runtime se trabaja con el último lock; la statusline marca `⚠ stale`; al reconectar, sync antes del siguiente claim.
63
94
 
64
95
  ## 5. Cola offline de eventos (`.claude/state/outbox/`)
65
96
 
66
- - Un archivo JSON por lote, con `client_event_id` (UUID) por evento ⇒ **idempotencia server-side** (reintentos seguros).
67
- - Despacho en background con backoff (1 s 5 s 30 s 5 min, tope); orden FIFO por agregado.
68
- - Cota: 5 MB / 72 h al superarla se descartan primero los eventos de telemetría fina (PostToolUse), **nunca** checkpoints, veredictos ni submits; el descarte se reporta como evento `telemetry_gap` al reconectar.
69
- - Flush forzado en Stop y en `trycore-build doctor`.
97
+ - Un archivo JSON por evento (no por lote — `runtime_enqueue_event`), con `client_event_id` (UUID) ⇒ **idempotencia server-side** (reintentos seguros). El despacho (`runtime_dispatch_outbox`) sí agrupa en un único `POST /events` por invocación, con el sobre que el hub exige: `{"events": [{client_event_id, event_type, payload, slice_id?}]}` — la clave interna `type` de los ficheros de la outbox se traduce a `event_type` **al armar el cuerpo** (el formato en disco no cambia; ficheros encolados por 0.10.x drenan sin migración). El ack real del hub es `{accepted[], duplicates[], rejected[{client_event_id, reason}]}` (issue #37).
98
+ - **Capa de compat de nombres/payloads en el mismo punto de salida** (issue #44): los productores encolan los nombres del **catálogo v2** del hub (`gate_verdict`, `checkpoint_recorded`, `slice_escalated`, `progress_noted`), pero los ficheros 0.10.x con los nombres viejos (`verdict_reported`, `checkpoint_created`, `escalation_raised`, `progress_note_recorded`) drenan traducidos tipo **y** claves de payload (`status`→`verdict` en mayúscula, `note`→`summary`, `reason/gate/phase`→`cause`) sin migración. Los tipos **sin equivalente** en el catálogo (`tool_use_recorded`, `branch_drift`, `front_integration_reported`) se apartan localmente a `outbox/rejected/` sin gastar red.
99
+ - **Un 4xx no es red caída**: un 4xx del lote (salvo 408/429) o un elemento en `rejected[]` del ack **no se reintenta** el fichero se mueve a `outbox/rejected/` con la razón loggeada y se avisa. Solo 408/429/5xx/corte de red conservan la cola con backoff (fail-open intacto).
100
+ - Despacho en background con backoff persistido (`1 s → 5 s → 30 s → 5 min`, tope; el estado vive en `outbox/.dispatch-state.json` y sobrevive entre invocaciones de hooks distintos). Orden FIFO global — el agrupado por-agregado nace con el catálogo de eventos (sub-slice C).
101
+ - Cota: 5 MB / 72 h — al superarla se descartan primero los eventos evictables (todo tipo fuera de la lista protegida), **nunca** `checkpoint_recorded`, `gate_verdict`, `slice_escalated`, `slice_submitted`, `slice_archived`, `wiring_*`, `project_fact_updated`, `handoff_recorded` ni el propio `telemetry_gap` (nombres del catálogo v2; los alias 0.10.x de los cuatro renombrados siguen protegidos porque la capa de compat los entrega). El descarte se reporta como evento `telemetry_gap` con el payload del catálogo `{dropped, window_h, reason}` (issue #44), coalescido en un único gap mientras la cola siga sobre la cota.
102
+ - Flush forzado en `Stop`: `session-stop.sh` deja el sentinela `outbox/.flush-request`; el daemon `heartbeat.sh` lo consume de forma asíncrona (nunca en el hilo del hook). `trycore-build doctor` **reporta** el tamaño/edad de la cola pero **no** dispara un flush síncrono — sigue sin implementar.
70
103
 
71
104
  ## 6. Declaración de tipos de asset (`asset-types.json` del paquete)
72
105
 
@@ -96,7 +129,12 @@ El paquete del plugin declara los tipos de documento que sabe generar. En el reg
96
129
  }
97
130
  ```
98
131
 
99
- Las skills generadoras producen la instancia y la **proponen**: `POST /projects/{id}/context/proposals {type_key, path, frontmatter, body}` — validada server-side contra el `frontmatter_schema`; la publica un ADMIN en el hub. Un agente jamás publica contexto.
132
+ Las skills generadoras producen la instancia y la **proponen** por la superficie de **agente**:
133
+ `POST /projects/{project_id}/context/agent-proposals {path, type_key, content}` — validada
134
+ server-side contra el `frontmatter_schema`; la publica un ADMIN en el hub. La superficie humana
135
+ `…/context/proposals` **no** la usa el arnés. Comando: `slice-ops.sh propose-asset --type-key K
136
+ --path P --content-file F` (`rc 6` si el contenido no valida contra el schema del tipo). **Un
137
+ agente jamás publica contexto.**
100
138
 
101
139
  ## 7. Errores y degradación (tabla normativa)
102
140
 
@@ -106,11 +144,58 @@ Las skills generadoras producen la instancia y la **proponen**: `POST /projects/
106
144
  | `409` en claim | Un reintento inmediato; luego informar "otro agente tomó la tarea" y pedir la siguiente. |
107
145
  | `422` (evento/veredicto rechazado por transición ilegal o schema) | **No reintentar**: mostrar la razón del servidor al modelo/usuario (compuerta mecánica funcionando); registrar localmente. |
108
146
  | Timeout/red caída | Modo offline (§4/§5); jamás bloquear PreToolUse ni el trabajo local. |
109
- | `410` en lease (expirado durante trabajo largo) | El slice pudo ser re-entregado: detener, hacer checkpoint local, re-claim (puede devolver el mismo slice si nadie lo tomó). |
147
+ | `409` en el renew de lease (`PUT /leases/renew`) | **No existe 410**: el servidor responde siempre `409` de cuerpo único (anti-oráculo). El daemon marca `lease_lost` en `.claude/state/heartbeat-status.json`, **no se apaga** (sigue despachando la cola) y la statusline muestra `⚠ lease`. La skill detiene el trabajo, hace checkpoint local y vuelve a reclamar. |
110
148
  | Reloj local desviado | El cliente usa siempre `expires_at` del servidor, nunca calcula TTL con reloj propio. |
111
149
 
112
150
  ## 8. Seguridad del cliente
113
151
 
114
- - El token vive solo en `.claude/state/runtime.credentials` (gitignoreado, `0600`); nunca en `settings.json` ni en el repo — no repetir el incidente de rutas/credenciales de máquina fosilizadas en `settings.json`.
152
+ - El token vive solo en `.claude/state/runtime.credentials` (`0600`, protegido en `.gitignore` desde `init`); nunca en `settings.json` ni en el repo — no repetir el incidente de rutas/credenciales de máquina fosilizadas en `settings.json`.
115
153
  - El harness no envía código fuente al runtime: solo referencias git, hashes, veredictos y metadatos (los payloads se validan contra schema; el servidor rechaza payloads sobredimensionados).
116
154
  - `trycore-build doctor` verifica: token válido, reloj razonable, lock fresco, outbox drenando, hashes sin drift.
155
+
156
+ ## 9. Daemon de heartbeat (ciclo de vida)
157
+
158
+ No existe evento Timer, los hooks son efímeros y un `PostToolUse` throttled no late durante una tool call larga. Por eso el latido es un daemon:
159
+
160
+ - **Singleton por repo**: `.claude/state/heartbeat.pid` + `.claude/state/heartbeat-sessions.json` (ppids de las sesiones interesadas). El arranque se serializa con un mutex de directorio (`mkdir`, atómico), con liberación por edad si queda huérfano.
161
+ - **Relanzamiento**: cada `SessionStart` registra su ppid y relanza si el pidfile está muerto; cada `PostToolUse` repite la comprobación barata (stat + `kill -0`, sin red). Un `kill -9` del daemon se detecta en el siguiente tool use.
162
+ - **Muerte**: cuando no queda **ningún** ppid registrado vivo (dos sesiones sobre el mismo repo comparten daemon; cerrar la primera no lo mata). Nunca se cuelga de `Stop` ni de `SessionEnd`.
163
+ - **Deberes por tick** (`TRYCORE_HEARTBEAT_TICK_S`, default 5 s): consumir `outbox/.flush-request` y despachar; cada `lease_ttl_s/3` (valor del servidor, mínimo 10 s), `PUT /leases/renew`. **Sin lease no se apaga**: los eventos de ámbito proyecto (pre-claim) necesitan despachador.
164
+
165
+ ## 10. Operaciones de skill (`slice-ops.sh` / `release-ops.sh`)
166
+
167
+ Las skills son prosa: **no** arman peticiones. Cada acto de dominio pasa por un ejecutable
168
+ instalado junto a los hooks —invocado, no registrado, igual que `context-sync.sh`— que decide por
169
+ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
170
+
171
+ | Comando | Superficie |
172
+ |---|---|
173
+ | `slice-ops.sh mode` | ninguna (lee `build-config.json`) |
174
+ | `slice-ops.sh claim` | `POST /tasks/next` (§3); `--epic` falla explícito, rc 2 (no hay claim dirigido, §3.6) |
175
+ | `slice-ops.sh next-step` | ninguna: lo **deriva el cliente** desde la caché de proyección |
176
+ | `slice-ops.sh gate <g> <pass\|fail\|na>` | `POST /slices/{id}/verdicts` (`{gate, verdict: PASS\|FAIL, evidence}`); offline → evento `gate_verdict`. `na` **no viaja**: el catálogo no lo admite (aviso local, rc 0, issue #44) |
177
+ | `slice-ops.sh wiring seed\|update` | eventos `wiring_checklist_seeded` (items `{item_id, kind, ref}`, sin `status`) / `wiring_item_updated` |
178
+ | `slice-ops.sh progress` | evento `progress_noted` (evictable) |
179
+ | `slice-ops.sh checkpoint` | `POST /checkpoints` (`summary`, no `note`); offline → evento `checkpoint_recorded {branch, commit_sha, summary}` |
180
+ | `slice-ops.sh submit` | `POST /slices/{id}/submit`; offline → evento `slice_submitted` con payload `{}` (el catálogo no admite campos) |
181
+ | `slice-ops.sh archive` | evento `slice_archived` con payload `{}` y `slice_id` (siempre por cola, idempotente) |
182
+ | `slice-ops.sh fact …` | hechos máquina → evento `project_fact_updated` (sin `slice_id`); hechos humanos → rc 6 con remisión al PATCH admin (§3) |
183
+ | `slice-ops.sh propose-asset` | `POST …/context/agent-proposals` (§6) |
184
+ | `slice-ops.sh status` | `GET /agent/context` (refresco) + ficheros locales |
185
+ | `slice-ops.sh escalate` | evento `slice_escalated {cause}` (gate y fase dentro del texto de la causa; exige slice activo en runtime) |
186
+ | `release-ops.sh verdict <line> <gate> <estado>` | `POST /releases/{line}/verdicts`; **sin fallback offline** (rc 5 y reintento al reconectar: el agregado `release` no entra por `POST /events`, issue #44) |
187
+ | `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
188
+ | `release-ops.sh front-integration <front>` | `POST /fronts/{id}/members/{agent_key}/integration`; **sin fallback offline** (rc 5: `front_integration_reported` no existe en el catálogo v2, issue #44) |
189
+
190
+ **Códigos de salida** (contrato con la prosa): `0` ok · `2` uso · `3` modo legacy (o claim en dual)
191
+ · `4` sin slice activo · `5` offline (encolado si el tipo tiene camino por la cola; si no, reintento manual al reconectar) · `6` rechazado por el servidor (no reintentar) ·
192
+ `7` sin trabajo.
193
+
194
+ **Modo `dual`:** el fichero es primario y estos comandos **espejan** cada transición a
195
+ `POST /projects/{project_id}/mirror/transitions` con la identidad fichero-primaria
196
+ (`slice_ref: {epic_code, openspec_change, branch, phase}`) y `origin: "mirror"`. Un rechazo del
197
+ reducer **no bloquea** el trabajo local: se avisa y queda como discrepancia del comparador.
198
+
199
+ **Lo que el agente NO hace** (superficies humanas, con PDP): cerrar una release, planificar/abrir/
200
+ drenar/cerrar un front, subir el bundle de import del grafo y publicar contexto. El arnés prepara,
201
+ propone y reporta; la persona decide en la consola del hub.
@@ -3,6 +3,27 @@
3
3
  # AUTO-ARME: inerte mientras no exista package.json.
4
4
  # No bloquea: al cerrar el turno, avisa si hay un slice activo con gates abiertos.
5
5
  set -uo pipefail
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ source "$HERE/lib/projection.sh"
8
+
9
+ # [EP-OR-08-B] En runtime la aritmética de gates vive en el servidor: este hook solo
10
+ # RENDERIZA lo que el claim/proyección ya dice. Nunca bloquea (Stop hook, exit 0 siempre).
11
+ if [ "$(runtime_mode)" = "runtime" ]; then
12
+ if [ -n "$(projection_get active_slice.slice_id "")" ]; then
13
+ abiertos="$(projection_get active_slice.gates '{}' | python3 -c 'import json,sys
14
+ try: g=json.load(sys.stdin)
15
+ except Exception: g={}
16
+ print(", ".join(k for k,v in g.items() if v is False))' 2>/dev/null)"
17
+ if [ -n "$abiertos" ]; then
18
+ epica="$(projection_get active_slice.epic_code '?')"
19
+ hus="$(projection_get active_slice.hus '[]' | tr -d '[]"')"
20
+ fase="$(projection_get active_slice.phase '?')"
21
+ echo "build-gate-check: slice $epica [${hus:-—}] en fase '$fase' con gates abiertos: $abiertos." >&2
22
+ echo " No archives ni abras PR hasta cerrarlos (ver building-a-slice / dod.md)." >&2
23
+ fi
24
+ fi
25
+ exit 0
26
+ fi
6
27
 
7
28
  ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
8
29
  [ -f "$ROOT/package.json" ] || exit 0
@@ -1,11 +1,20 @@
1
1
  #!/usr/bin/env bash
2
2
  # context-monitor.sh — PostToolUse|PreCompact|Stop. Lee el puente, aplica umbrales,
3
- # inyecta additionalContext y (en critical) escribe handoff. Fail-open.
3
+ # inyecta additionalContext y en `critical` registra el handoff. Fail-open.
4
+ # [EP-OR-08-B] Comportamiento por modo:
5
+ # - legacy: v0.8.5 exacto (handoff escrito en build-state.json).
6
+ # - dual: igual + el mismo handoff se emite como evento handoff_recorded (espejo).
7
+ # - runtime: NO se escribe el fichero; el handoff es SOLO el evento, y el guard
8
+ # once-per-sesión vive en una marca local (.claude/state/.handoff-<session_id>).
4
9
  set -uo pipefail
5
10
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- source "$HERE/lib/state-io.sh"
11
+ source "$HERE/lib/runtime-client.sh"
12
+ source "$HERE/lib/projection.sh"
7
13
  payload="$(cat)"; command -v python3 >/dev/null 2>&1 || exit 0
8
14
 
15
+ MODE="$(runtime_mode)"
16
+ [ "$MODE" != "runtime" ] && source "$HERE/lib/state-io.sh"
17
+
9
18
  read -r SID EVT <<<"$(printf '%s' "$payload" | python3 -c '
10
19
  import json,sys
11
20
  try: p=json.load(sys.stdin)
@@ -21,11 +30,27 @@ BRIDGE="${TMPDIR:-/tmp}/claude-ctx-$sanitized_sid.json"
21
30
 
22
31
  WARN="$(config_get context.warning_pct 35)"; CRIT="$(config_get context.critical_pct 25)"
23
32
  STALE="$(config_get context.stale_seconds 60)"; AUTO="$(config_get context.auto_checkpoint false)"
24
- STATE="$(state_path)"
25
33
 
26
- python3 - "$BRIDGE" "$WARN" "$CRIT" "$STALE" "$EVT" "$STATE" "$AUTO" <<'PY' 2>/dev/null || true
34
+ if [ "$MODE" = "runtime" ]; then
35
+ STATE=""
36
+ MARK="$(config_root)/.claude/state/.handoff-$sanitized_sid"
37
+ HINT="$(projection_get active_slice.wiring_failing '[]' | python3 -c 'import json,sys
38
+ try: w=json.load(sys.stdin)
39
+ except Exception: w=[]
40
+ ids=[str(i.get("item_id")) for i in w[:6] if i.get("item_id")]
41
+ print("cablear: "+", ".join(ids) if ids else "")' 2>/dev/null)"
42
+ [ -n "$HINT" ] || HINT="$(projection_get active_slice.resume_hint "")"
43
+ else
44
+ STATE="$(state_path)"
45
+ MARK=""
46
+ HINT=""
47
+ fi
48
+ mkdir -p "$(config_root)/.claude/state"
49
+ EVENT_OUT="$(mktemp)" || exit 0
50
+
51
+ python3 - "$BRIDGE" "$WARN" "$CRIT" "$STALE" "$EVT" "$STATE" "$AUTO" "$MODE" "$EVENT_OUT" "$MARK" "$HINT" <<'PY' 2>/dev/null || true
27
52
  import json,sys,os,time,tempfile
28
- bridge,warn,crit,stale,evt,state,auto=sys.argv[1:8]
53
+ bridge,warn,crit,stale,evt,state,auto,mode,event_out,mark,hint=sys.argv[1:12]
29
54
  warn,crit,stale=int(warn),int(crit),int(stale)
30
55
  try: b=json.load(open(bridge))
31
56
  except Exception: sys.exit(0)
@@ -34,18 +59,21 @@ rem=b.get("remaining_pct",100)
34
59
  sev = "critical" if rem<=crit else ("warning" if rem<=warn else None)
35
60
  if not sev: sys.exit(0)
36
61
 
37
- # ¿slice activo? handoff solo en critical y una vez por sesión.
38
- recorded=False
39
- if sev=="critical" and os.path.exists(state):
62
+ iso=time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime())
63
+ recorded=False # handoff persistido en el fichero (legacy|dual)
64
+ emit=False # handoff que hay que encolar como evento (dual|runtime)
65
+ resume=hint or "revisar progreso y continuar"
66
+
67
+ if sev=="critical" and mode!="runtime" and state and os.path.exists(state):
40
68
  try:
41
69
  d=json.load(open(state)); s=d.get("active_slice")
42
70
  if s:
43
71
  sc=s.setdefault("session_continuity",{})
44
72
  if not sc.get("critical_recorded"):
45
- iso=time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime())
46
73
  sc["stopped_at"]=f"context exhaustion at {rem}% ({iso})"
47
74
  failing=[w["id"] for w in (s.get("wiring_checklist") or []) if w.get("status")=="failing"]
48
- sc["resume_hint"]=("cablear: "+", ".join(failing[:6])) if failing else "revisar progreso y continuar"
75
+ resume=("cablear: "+", ".join(failing[:6])) if failing else "revisar progreso y continuar"
76
+ sc["resume_hint"]=resume
49
77
  sc["critical_recorded"]=True
50
78
  sc["auto_continue"]=(auto=="true")
51
79
  s.setdefault("progress_log",[]).append(
@@ -60,21 +88,60 @@ if sev=="critical" and os.path.exists(state):
60
88
  except OSError: pass
61
89
  raise
62
90
  except Exception: pass
91
+ if recorded and mode=="dual":
92
+ emit=True
93
+ elif sev=="critical" and mode=="runtime":
94
+ # El guard once-per-sesión no puede vivir en el fichero (no lo hay): marca local.
95
+ if mark and not os.path.exists(mark):
96
+ try:
97
+ with open(mark,"w") as m: m.write(iso)
98
+ emit=True
99
+ except Exception:
100
+ emit=False
63
101
 
102
+ if emit:
103
+ try:
104
+ with open(event_out,"w") as out:
105
+ json.dump({"note":f"handoff auto a {rem}% de contexto restante",
106
+ "resume_hint":resume,
107
+ "stopped_at":f"context exhaustion at {rem}% ({iso})"},out,ensure_ascii=False)
108
+ except Exception:
109
+ pass
110
+
111
+ transition = recorded or emit
64
112
  if sev=="warning":
65
113
  msg=(f"⚠️ Contexto al {rem}% restante. Acércate a un punto natural de corte "
66
114
  "(fin de fase/gate). No inicies trabajo complejo nuevo.")
67
115
  else:
68
- tail=(" Handoff escrito en build-state.json (session_continuity)." if recorded else "")
116
+ if mode=="runtime":
117
+ tail=(" Handoff registrado en el runtime (handoff_recorded)." if emit else "")
118
+ origen="El estado vive en el runtime;"
119
+ else:
120
+ tail=(" Handoff escrito en build-state.json (session_continuity)." if recorded else "")
121
+ origen="El estado ya vive en build-state.json;"
69
122
  cont=(" auto_checkpoint=ON: continúa en sesión fresca." if auto=="true" else
70
123
  " Avisa al usuario para reiniciar en un punto natural.")
71
- msg=(f"🛑 Contexto CRÍTICO al {rem}% restante.{tail} El estado ya vive en build-state.json;"
124
+ msg=(f"🛑 Contexto CRÍTICO al {rem}% restante.{tail} {origen}"
72
125
  f" no reescribas handoff manual.{cont}")
73
126
  # En 'Stop', inyectar additionalContext RE-LANZA el turno (re-prompt): repetirlo en cada
74
127
  # intento de cierre entra en bucle hasta el tope CLAUDE_CODE_STOP_HOOK_BLOCK_CAP (=9→override).
75
128
  # Por eso en 'Stop' re-lanzamos como MUCHO una vez por sesión y solo en la TRANSICIÓN a crítico
76
- # (recorded=True, el instante en que se graba el handoff). 'warning' nunca inyecta en 'Stop'
77
- # (el nudge solo sirve mientras se trabaja). Fuera de 'Stop' se inyecta con normalidad.
78
- if evt=="Stop" and not (sev=="critical" and recorded): sys.exit(0)
129
+ # (el instante en que se graba/emite el handoff). 'warning' nunca inyecta en 'Stop'.
130
+ if evt=="Stop" and not (sev=="critical" and transition): sys.exit(0)
79
131
  print(json.dumps({"hookSpecificOutput":{"hookEventName":evt,"additionalContext":msg}}))
80
132
  PY
133
+
134
+ # El evento se encola FUERA del bloque python: encolar es competencia del cliente bash
135
+ # (runtime_enqueue_event), y así el stdout del hook queda limpio pase lo que pase.
136
+ # handoff_recorded es de ámbito slice: los eventos de ámbito slice llevan slice_id, sin
137
+ # slice_id conocido NO se encolan (el servidor los rechazaría elemento a elemento) — misma
138
+ # guardia que event-emitter.sh. En `dual` es el caso NORMAL, no el borde: dual no reclama
139
+ # vía runtime, así que /agent/context no trae active_slice y sin esta guardia el evento se
140
+ # encolaba sin slice_id, quedando como poison pill en RUNTIME_OUTBOX_PROTECTED si el
141
+ # servidor rechazaba el lote entero.
142
+ SLICE_ID_HANDOFF="$(projection_get active_slice.slice_id "")"
143
+ if [ -s "$EVENT_OUT" ] && [ "$MODE" != "legacy" ] && [ -n "$SLICE_ID_HANDOFF" ]; then
144
+ runtime_enqueue_event "handoff_recorded" "$(cat "$EVENT_OUT")" "$SLICE_ID_HANDOFF" >/dev/null 2>&1
145
+ fi
146
+ rm -f "$EVENT_OUT"
147
+ exit 0
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env bash
2
+ # context-sync.sh — sincronización de contexto content-addressed [EP-OR-08-B]
3
+ # (protocolo-cliente-runtime §4). NO es un hook registrado: lo invoca session-start.sh y,
4
+ # desde el sub-slice C, las skills antes de reclamar.
5
+ # uso: context-sync.sh [manifest_hash_esperado]
6
+ # Fail-open: sin runtime, sin python3 o con manifiesto ilegible sale 0 y deja el lock
7
+ # anterior intacto — se sigue trabajando con el último contexto sincronizado.
8
+ set -uo pipefail
9
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ source "$HERE/lib/runtime-client.sh"
11
+
12
+ MODE="$(runtime_mode)"
13
+ [ "$MODE" = "legacy" ] && exit 0
14
+ command -v python3 >/dev/null 2>&1 || exit 0
15
+
16
+ EXPECTED="${1:-}"
17
+ [ -n "$EXPECTED" ] || EXPECTED="$(runtime_field manifest_hash)"
18
+ CURRENT="$(runtime_lock_manifest_hash)"
19
+ [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$CURRENT" ] && exit 0
20
+
21
+ PID="$(runtime_field project_id)"
22
+ [ -n "$PID" ] || exit 0
23
+
24
+ MANIFEST="$(runtime_get "/projects/$PID/context/manifest")"
25
+ [ "$(runtime_http_status)" = "200" ] || exit 0 # offline: se sigue con el lock viejo
26
+
27
+ ROOT="$(config_root)"
28
+ WORK="$(mktemp -d)" || exit 0
29
+ trap 'rm -rf "$WORK"' EXIT
30
+ printf '%s' "$MANIFEST" > "$WORK/manifest.json"
31
+
32
+ # Validación temprana: el manifiesto debe parsear como objeto JSON con un 'manifest_hash'
33
+ # no vacío. Un body no-JSON, un array, o una página de error de proxy servida con status
34
+ # 200 se descartan AQUÍ, antes de planificar/descargar/sellar nada — el lock existente
35
+ # queda intacto y no se emite context/synced con un hash falso.
36
+ NEWHASH="$(python3 -c '
37
+ import json,sys
38
+ try:
39
+ d=json.load(open(sys.argv[1]))
40
+ print(d.get("manifest_hash","") if isinstance(d,dict) else "")
41
+ except Exception:
42
+ print("")
43
+ ' "$WORK/manifest.json" 2>/dev/null)"
44
+ [ -n "$NEWHASH" ] || exit 0
45
+
46
+ # Plan de descarga: una línea "path<TAB>sha256" por archivo GOBERNADO que cambió.
47
+ # Las rutas se validan AQUÍ (antes de tocar disco): solo config/, rules/ y docs-cache/,
48
+ # nunca absolutas, nunca con "..". Un manifiesto hostil o con una entrada de destino no
49
+ # gobernado (CLAUDE.md, que necesita markers.ts — sub-slice D) no escribe nada: se lista
50
+ # como "no aplicado" y NO impide converger el hash.
51
+ python3 - "$WORK/manifest.json" "$(runtime_lock_path)" "$WORK/skipped.txt" > "$WORK/plan.tsv" <<'PY' 2>/dev/null
52
+ import json,sys,posixpath
53
+ ALLOWED_PREFIXES=("config/","rules/","docs-cache/")
54
+ try:
55
+ man=json.load(open(sys.argv[1]))
56
+ except Exception:
57
+ sys.exit(0)
58
+ try:
59
+ lock=json.load(open(sys.argv[2]))
60
+ except Exception:
61
+ lock={}
62
+ have={}
63
+ for f in (lock.get("files") or []):
64
+ if isinstance(f,dict) and f.get("path"):
65
+ have[f["path"]]=f.get("sha256")
66
+ skipped=[]
67
+ for f in (man.get("files") or []):
68
+ if not isinstance(f,dict):
69
+ continue
70
+ path=f.get("path") or ""
71
+ sha=f.get("sha256") or ""
72
+ if (not path or not sha or path.startswith("/") or ".." in path.split("/")
73
+ or path != posixpath.normpath(path)
74
+ or not path.startswith(ALLOWED_PREFIXES)):
75
+ skipped.append(path or "(sin ruta)")
76
+ continue
77
+ if have.get(path) == sha:
78
+ continue
79
+ sys.stdout.write(path+"\t"+sha+"\n")
80
+ sys.stdout.flush()
81
+ try:
82
+ with open(sys.argv[3],"w") as out:
83
+ for s in skipped:
84
+ out.write(s+"\n")
85
+ except Exception:
86
+ pass
87
+ PY
88
+
89
+ # Si el plan no se generó (python falló), se trata como plan vacío: nada que descargar,
90
+ # nada que sellar. La redirección nunca debe fallar por fichero ausente.
91
+ [ -f "$WORK/plan.tsv" ] || : > "$WORK/plan.tsv"
92
+ plan=()
93
+ while IFS= read -r line; do
94
+ [ -n "$line" ] && plan+=("$line")
95
+ done < "$WORK/plan.tsv"
96
+
97
+ applied=0
98
+ planned=${#plan[@]}
99
+ if [ "$planned" -gt 0 ]; then
100
+ for line in "${plan[@]}"; do
101
+ # Campos separados por TAB. Se parten con `cut` (delimitador por defecto = tabulador)
102
+ # en vez de expansión de parámetros: un tabulador literal dentro de ${…%%…} es
103
+ # invisible y cualquier editor que lo convierta en espacios rompería el parseo en
104
+ # silencio, dejando rutas con el sha256 pegado.
105
+ rel="$(printf '%s' "$line" | cut -f1)"
106
+ sha="$(printf '%s' "$line" | cut -f2)"
107
+ enc="$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$rel" 2>/dev/null)"
108
+ [ -n "$enc" ] || continue
109
+ body="$(runtime_get "/projects/$PID/context/files?path=$enc&sha256=$sha")"
110
+ [ "$(runtime_http_status)" = "200" ] || continue
111
+ printf '%s' "$body" > "$WORK/blob.json"
112
+ if python3 - "$WORK/blob.json" "$ROOT/.claude/$rel" <<'PY' 2>/dev/null
113
+ import json,sys,os,tempfile
114
+ blob_path,dest=sys.argv[1],sys.argv[2]
115
+ raw=open(blob_path,encoding="utf-8",errors="replace").read()
116
+ try:
117
+ d=json.loads(raw)
118
+ content=d["content"] if isinstance(d,dict) and "content" in d else raw
119
+ except Exception:
120
+ content=raw
121
+ if not isinstance(content,str):
122
+ content=json.dumps(content,indent=2,ensure_ascii=False)
123
+ dirn=os.path.dirname(dest) or "."
124
+ os.makedirs(dirn,exist_ok=True)
125
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".ctx-sync.",suffix=".tmp")
126
+ try:
127
+ with os.fdopen(fd,"w") as out:
128
+ out.write(content); out.flush(); os.fsync(out.fileno())
129
+ os.replace(tmp,dest) # sustitución atómica, patrón v0.8.1
130
+ except Exception:
131
+ try: os.unlink(tmp)
132
+ except OSError: pass
133
+ sys.exit(1)
134
+ PY
135
+ then
136
+ applied=$((applied + 1))
137
+ fi
138
+ done
139
+ fi
140
+
141
+ if [ "$applied" -lt "$planned" ]; then
142
+ # Convergencia parcial: NO se sella el hash nuevo (el lock mentiría sobre lo que hay en
143
+ # disco). El próximo SessionStart reintenta solo lo que falta.
144
+ echo "⚠ contexto: $applied/$planned archivo(s) sincronizados; el lock sigue en '${CURRENT:-vacío}'. Se reintenta en la próxima sesión." >&2
145
+ exit 0
146
+ fi
147
+
148
+ FILES_JSON="$(python3 - "$WORK/manifest.json" <<'PY' 2>/dev/null
149
+ import json,sys
150
+ try:
151
+ man=json.load(open(sys.argv[1]))
152
+ except Exception:
153
+ man={}
154
+ out=[]
155
+ for f in (man.get("files") or []):
156
+ if isinstance(f,dict) and f.get("path"):
157
+ out.append({"path":f.get("path"),"sha256":f.get("sha256"),
158
+ "type_key":f.get("type_key"),"version":f.get("version")})
159
+ print(json.dumps(out,ensure_ascii=False))
160
+ PY
161
+ )"
162
+ [ -n "$FILES_JSON" ] || FILES_JSON='[]'
163
+ runtime_lock_write "$NEWHASH" "$FILES_JSON"
164
+
165
+ # Señal de sincronización: endpoint propio (es la única señal SIN slice_id del protocolo).
166
+ # 422 = manifiesto desconocido para el servidor -> no se reintenta ni se encola.
167
+ CHANGED_JSON="$(python3 - "$WORK/plan.tsv" <<'PY' 2>/dev/null
168
+ import json,sys
169
+ paths=[]
170
+ try:
171
+ for line in open(sys.argv[1]):
172
+ line=line.rstrip("\n")
173
+ if line:
174
+ paths.append(line.split("\t")[0])
175
+ except Exception:
176
+ pass
177
+ print(json.dumps(paths,ensure_ascii=False))
178
+ sys.stdout.flush()
179
+ PY
180
+ )"
181
+ [ -n "$CHANGED_JSON" ] || CHANGED_JSON='[]'
182
+ runtime_post "/context/synced" "{\"from_hash\":\"$CURRENT\",\"to_hash\":\"$NEWHASH\",\"files_changed\":$CHANGED_JSON}" >/dev/null
183
+ ST="$(runtime_http_status)"
184
+ [ "$ST" = "422" ] && echo "⚠ el runtime no reconoce el manifiesto sincronizado (422): revisa 'trycore-build doctor'." >&2
185
+
186
+ if [ "$applied" -gt 0 ]; then
187
+ echo "⬆ contexto: $applied archivo(s) actualizados (${CURRENT:-sin lock} → $NEWHASH)"
188
+ fi
189
+ if [ -s "$WORK/skipped.txt" ]; then
190
+ echo " No aplicados (destino no gobernado por este canal): $(tr '\n' ' ' < "$WORK/skipped.txt")"
191
+ fi
192
+ exit 0