@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.
- package/.claude-plugin/plugin.json +1 -1
- package/GOVERNANCE.md +27 -4
- package/INSTALL.md +27 -5
- package/METODOLOGIA.md +55 -5
- package/README.md +39 -6
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +33 -7
- package/agents/build/dor-dod-gatekeeper.md +13 -5
- package/agents/build/wiring-adversarial-verifier.md +52 -5
- package/commands/build/architect.md +1 -1
- package/commands/build/claim.md +46 -0
- package/commands/build/escalate.md +36 -0
- package/commands/build/front.md +9 -3
- package/commands/build/onboard.md +75 -14
- package/commands/build/prototype.md +3 -2
- package/commands/build/reflect.md +60 -40
- package/commands/build/release.md +10 -7
- package/commands/build/resume.md +33 -13
- package/commands/build/slice.md +32 -27
- package/commands/build/status.md +35 -0
- package/commands/build/work.md +11 -8
- package/config/build-config.template.json +4 -0
- package/dist/cli.js +32 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/init.js +84 -1
- package/dist/commands/migrate.js +153 -0
- package/dist/commands/status.js +34 -0
- package/dist/lib/normalize.js +1123 -0
- package/dist/lib/paths.js +6 -0
- package/dist/lib/runtime-client.js +196 -0
- package/dist/lib/settings-merge.js +3 -3
- package/dist/lib/state-bundle.js +150 -0
- package/docs/commands.md +25 -8
- package/docs/getting-started.md +1 -0
- package/docs/hooks.md +114 -27
- package/docs/runtime/guia-modo-dual-y-migracion.md +143 -0
- package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
- package/docs/runtime/protocolo-cliente-runtime.md +120 -35
- package/hooks/build/build-gate-check.sh +21 -0
- package/hooks/build/context-monitor.sh +82 -15
- package/hooks/build/context-sync.sh +192 -0
- package/hooks/build/design-source-guard.sh +30 -2
- package/hooks/build/dual-compare.sh +92 -0
- package/hooks/build/event-emitter.sh +32 -0
- package/hooks/build/gitflow-guard.sh +164 -14
- package/hooks/build/heartbeat.sh +259 -0
- package/hooks/build/lib/agent-context.sh +139 -0
- package/hooks/build/lib/config.sh +27 -0
- package/hooks/build/lib/projection.sh +71 -0
- package/hooks/build/lib/runtime-client.sh +625 -0
- package/hooks/build/lib/runtime-ops.sh +227 -0
- package/hooks/build/lib/state-io.sh +5 -18
- package/hooks/build/load-build-state.sh +64 -2
- package/hooks/build/reflect-nudge.sh +15 -0
- package/hooks/build/release-gate-nudge.sh +15 -0
- package/hooks/build/release-ops.sh +171 -0
- package/hooks/build/scaffold-guard.sh +29 -2
- package/hooks/build/session-start.sh +103 -0
- package/hooks/build/session-stop.sh +22 -0
- package/hooks/build/slice-ops.sh +948 -0
- package/hooks/build/stack-guard.sh +8 -0
- package/hooks/build/statusline-bridge.sh +24 -3
- package/hooks/build-harness.json +16 -0
- package/package.json +3 -3
- package/scripts/check-agnostic.sh +3 -1
- package/scripts/check-pack-clean.sh +31 -0
- package/scripts/check-runtime-purity.sh +43 -0
- package/scripts/denylist.txt +4 -0
- package/scripts/lib/front-plan.py +4 -0
- package/scripts/lib/graph-bundle.py +181 -0
- package/scripts/runtime-purity-allow.txt +5 -0
- package/scripts/smoke-test.sh +1 -1
- package/scripts/tests/lib/http-stub.py +46 -0
- package/scripts/tests/test-baseline-verdict.sh +92 -0
- package/scripts/tests/test-config.sh +25 -0
- package/scripts/tests/test-hooks-runtime.sh +828 -0
- package/scripts/tests/test-install.sh +103 -0
- package/scripts/tests/test-runtime-client.sh +298 -0
- package/scripts/tests/test-schema.sh +29 -1
- package/scripts/tests/test-skill-ops.sh +1367 -0
- package/skills/building-a-micro-change/SKILL.md +22 -4
- package/skills/building-a-slice/SKILL.md +55 -21
- package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
- package/skills/building-a-slice/references/dod.md +12 -3
- package/skills/building-a-slice/references/dor.md +3 -2
- package/skills/building-a-slice/references/evidence-budget.md +51 -0
- package/skills/building-a-slice/references/exploration-fanout.md +1 -1
- package/skills/building-a-slice/references/gitflow.md +1 -1
- package/skills/building-a-slice/references/regression-baseline.md +67 -0
- package/skills/building-a-slice/references/runtime-protocol.md +75 -0
- package/skills/building-a-slice/references/state-protocol.md +12 -1
- package/skills/building-a-slice/workflows/README.md +7 -3
- package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
- package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
- package/skills/managing-parallel-front/SKILL.md +32 -16
- package/skills/openspec-archive-change/SKILL.md +15 -0
- package/skills/prototyping-screens/SKILL.md +9 -5
- package/skills/releasing-a-version/SKILL.md +26 -16
- package/skills/releasing-a-version/references/release-dod.md +7 -5
- package/skills/releasing-a-version/workflows/README.md +2 -1
- package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
- package/skills/setup-architecture/SKILL.md +4 -2
- package/state/README.md +16 -1
- package/state/build-state.schema.json +2 -1
- package/templates/CLAUDE.md.template +16 -0
- package/templates/settings-hooks.template.json +8 -4
- 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`,
|
|
8
|
-
- **Registro:** `POST /agents/register {harness_version,
|
|
9
|
-
- **Identidad local:** `
|
|
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
|
|
16
|
-
|
|
|
17
|
-
|
|
|
18
|
-
|
|
|
19
|
-
|
|
|
20
|
-
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 →
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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 (
|
|
57
|
-
2. Distinto ⇒ `GET /projects/{id}/context/manifest`; diff por `sha256` archivo a archivo; `GET /context/files
|
|
58
|
-
3.
|
|
59
|
-
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
|
|
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)
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
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
|
|
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
|
-
| `
|
|
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` (
|
|
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
|
|
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/
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
38
|
-
recorded=False
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
# (
|
|
77
|
-
|
|
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
|