@ingeniomaps/cauce 0.60.0 → 0.61.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/CHANGELOG.md +117 -0
- package/agents/roles/system/software-architect/evaluations/cases/08-ci-enforcement-assumed/guia-arquitectura-go.md +98 -0
- package/agents/roles/system/software-architect/evaluations/cases/08-ci-enforcement-assumed/linters/.arch-lint.yml +42 -0
- package/agents/roles/system/software-architect/evaluations/cases/08-ci-enforcement-assumed.md +15 -0
- package/automatization/hooks/guard-shell-boundary.sh +3 -0
- package/automatization/workflows/autobuild.js +10 -3
- package/engine/cli/args.js +1 -0
- package/engine/cli/catalog.js +6 -1
- package/engine/cli/ops.js +2 -0
- package/engine/cli/planning.js +51 -4
- package/engine/config/paths.js +27 -0
- package/engine/config/validate.js +18 -1
- package/engine/hooks/files.js +8 -7
- package/engine/hooks/input.js +32 -0
- package/engine/hooks/run.js +7 -1
- package/engine/hooks/shell.js +104 -2
- package/engine/planning/adoption.js +48 -0
- package/engine/planning/contracts.js +28 -38
- package/engine/planning/parser.js +50 -2
- package/engine/planning/sizing.js +50 -0
- package/engine/schemas/ops-config.schema.json +8 -0
- package/package.json +1 -1
- package/template/AGENTS.md +8 -2
- package/template/planning/rules/system/code-shape.md +13 -0
- package/template/planning/rules/system/commits.md +10 -0
- package/template/planning/rules/system/conduct.md +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,123 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
|
|
|
14
14
|
unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
|
|
15
15
|
diseño — eso vive en el commit y en el código.
|
|
16
16
|
|
|
17
|
+
## [0.61.0] - 2026-09-06
|
|
18
|
+
|
|
19
|
+
### Agregado
|
|
20
|
+
|
|
21
|
+
- **`R11` se recorre antes de entregar, como `R14` y `R15`.** Antes de dar por terminado un cambio se
|
|
22
|
+
repasan los comentarios que agrega, uno por uno, y de cada uno se contesta si alguien lo preguntaría,
|
|
23
|
+
si su razón ya está escrita en otro lado y si está en el destino que le toca. Es la tercera regla de
|
|
24
|
+
la misma familia —algo que releer no encuentra, porque quien lo escribió ya sabe por qué y la copia se
|
|
25
|
+
lee bien precisamente porque lo que dice es cierto— y era la única sin la pasada. La regla dice
|
|
26
|
+
también que una puerta que mida esto ayuda y no la reemplaza: las dos formas que más aparecen son la
|
|
27
|
+
razón repetida apenas por debajo del umbral y el comentario que no repite a ningún otro porque repite
|
|
28
|
+
el nombre que tiene al lado, y bajar el umbral hasta agarrarlas empieza a marcar lo que está bien.
|
|
29
|
+
|
|
30
|
+
- **Un guard nuevo, `shell-boundary`: el destino de un comando también se juzga.** El límite de raíces
|
|
31
|
+
sólo se disparaba con `Edit` y `Write`, así que el archivo que una herramienta no dejaba escribir se
|
|
32
|
+
escribía sin obstáculo con un heredoc por `Bash`: frenaba a quien actuaba de buena fe y no a quien
|
|
33
|
+
quería pasar. **Qué cambia para vos**: se leen las redirecciones, `tee`, `truncate`, `cp`, `mv`,
|
|
34
|
+
`install`, `rsync` y `sed -i` —sin `-i`, `sed` lee y no se juzga—, y el bloqueo nombra la salida
|
|
35
|
+
—declarar la ruta en `writableOutsideRoots`— en vez de sólo decir que no. `/dev/null` y el temporal del sistema no se juzgan, y un destino armado con una variable
|
|
36
|
+
que no sea `$HOME` tampoco: adivinar su valor sería inventar un límite. **No es completo y no se
|
|
37
|
+
presenta como si lo fuera**: `eval`, un heredoc dentro de `bash -c` o un script propio escriben igual
|
|
38
|
+
y ningún patrón los ve. Frena la forma habitual, como el resto de `destructive`.
|
|
39
|
+
|
|
40
|
+
- **`ops adopt` — adoptar Cauce en un proyecto que ya tiene historia.** `check` le exigía a toda entrada
|
|
41
|
+
de `DONE.md` los mismos campos, incluida la que se escribió bajo otro contrato o bajo ninguno, y las
|
|
42
|
+
únicas salidas eran escribir `tests: n/a — razón` en cada una vieja —que deja la exención adentro del
|
|
43
|
+
campo de evidencia, donde alguien la va a copiar a una entrada nueva— o dejar `check` en rojo
|
|
44
|
+
permanente. **Qué cambia para vos**: `ops adopt <planning-dir>` genera una vez
|
|
45
|
+
`planning/.adoption-baseline` con las entradas que hoy no cumplen, y `check` deja de juzgarlas. El
|
|
46
|
+
perdón es por entrada y no por campo, así que una historia vieja con un `commit:` de otro formato
|
|
47
|
+
también queda afuera. `check` muestra la cuenta en cada corrida y avisa cuando una exenta ya cumple el
|
|
48
|
+
contrato o cuando nombra una entrada que no existe, para que la lista se achique en vez de envejecer;
|
|
49
|
+
achicarla es borrar el renglón. `adopt` no se vuelve a correr sobre un baseline que ya existe.
|
|
50
|
+
|
|
51
|
+
- **`writableOutsideRoots` — declarar rutas escribibles que no son raíces de código.** El guard de
|
|
52
|
+
límites bloquea todo lo que caiga fuera de la raíz de ops y de `workspaceRoots`, y ahí cae el
|
|
53
|
+
directorio donde tu runner guarda su memoria entre sesiones, que no es código de tu proyecto.
|
|
54
|
+
Declararlo raíz para que pasara metía un árbol ajeno en `scan` y en el inventario de credenciales, y
|
|
55
|
+
era el único guard que bloqueaba por política sin salida declarada. **Qué cambia para vos**: una lista
|
|
56
|
+
opcional de rutas en `ops.config.json` —`~` se expande a tu casa, el resto se resuelve contra la raíz
|
|
57
|
+
de ops— y `check` te las muestra resueltas en cada corrida, porque una exención que no se ve es un
|
|
58
|
+
límite que ya no existe. No declarar ninguna sigue siendo el caso normal: al actualizar no hay nada
|
|
59
|
+
que agregar.
|
|
60
|
+
|
|
61
|
+
### Corregido
|
|
62
|
+
|
|
63
|
+
- **El «Contexto relevante» de la épica llega a quien construye la tarea.** `ops context` resolvía la
|
|
64
|
+
épica y mandaba número, título y estado; la sección que dice contra qué se construye se quedaba en el
|
|
65
|
+
archivo, y `/autobuild` le dice al ejecutor que lea cuatro archivos «una sola vez y no leas nada más»,
|
|
66
|
+
entre los cuales el roadmap no está. Recibía el resultado a lograr y la condición que lo cierra, nunca
|
|
67
|
+
la razón por la que existe. **Qué cambia para vos**: `ops context` imprime la sección en líneas `CTX`
|
|
68
|
+
y la trae en `--json` bajo `epic.context`, y la fase Plan de `/autobuild` la recibe —ya la pedía en su
|
|
69
|
+
prompt y nunca le llegaba—. Viaja entera: nada en esa salida se recorta, y una lista de viñetas no
|
|
70
|
+
tiene primer párrafo que resuma al resto. Si en tu proyecto esa sección es enorme, se va a notar acá.
|
|
71
|
+
|
|
72
|
+
- **La barra de cinco condiciones de R17 también cuenta la aceptación que la tarea escribe.** Contaba
|
|
73
|
+
sólo los criterios heredados con `(→ CN)`, así que una tarea con su aceptación en la línea —la forma
|
|
74
|
+
que el molde muestra primero— contaba cero y nunca cruzaba el umbral; la salida que R17 describe,
|
|
75
|
+
`(sin partir: <razón>)`, no se le pedía jamás. **Qué cambia para vos**: es probable que aparezcan
|
|
76
|
+
mensajes en tareas que hoy pasan, y en un proyecto recién adoptado pueden ser varias a la vez; la
|
|
77
|
+
salida es la misma de siempre. Se cuenta lo que vos marcaste —los `(1)`, `(2)`… cuando hay más de uno,
|
|
78
|
+
y si no, lo que separaste con `;`— y sub-cuenta a propósito: una frase larga con comas vale una. El
|
|
79
|
+
mensaje dice cuál de los dos contó, para que «8» no mande a buscar ocho referencias que no existen.
|
|
80
|
+
|
|
81
|
+
- **R10 dice cuál de sus seis actos comprueba el motor.** La regla prometía «la autorización
|
|
82
|
+
configurada para el proyecto» para push, PR, merge, tags, deploy y rollback, y el motor comprueba uno.
|
|
83
|
+
Medido con `allowPush` apagado: `gh pr merge`, `gh release create`, `gh workflow run`, `git tag` y un
|
|
84
|
+
`kubectl apply` pasan todos. **Qué cambia para vos**: nada de lo que hoy funciona deja de funcionar —
|
|
85
|
+
lo que cambia es que la regla ya no promete lo que no comprueba, y dice que a esos cinco los sostiene
|
|
86
|
+
ella y el review. Un deploy no tiene forma reconocible en un comando; un guard tendría que adivinarla.
|
|
87
|
+
|
|
88
|
+
- **`--force` y `--amend` se frenan por su cuenta.** R10 enumera seis actos de publicación y el guard
|
|
89
|
+
comprobaba uno, sin distinguir lo que la prosa distingue: `git push` y `git push --force` caían en el
|
|
90
|
+
mismo patrón, así que `allowPush: true` habilitaba también reescribir historia ya publicada. Y
|
|
91
|
+
`git commit --amend` no lo miraba nadie, con la llave prendida o apagada, aunque R8 lo prohíbe sin
|
|
92
|
+
excepción. **Qué cambia para vos**: si tu proyecto publica con `allowPush`, un `--force` ahora se
|
|
93
|
+
frena igual —con su propio mensaje, que dice que la llave no lo desbloquea—, y un `--amend` también.
|
|
94
|
+
Si venías usando alguno de los dos, lo vas a notar; la salida es un push normal o un commit nuevo.
|
|
95
|
+
|
|
96
|
+
- **Un mensaje de commit ya no dispara los guards que nombra.** `git commit -m "fix: bloquear git push
|
|
97
|
+
--force"` caía por el guard de publicación, y nombrar `rm -rf` dentro de una explicación caía por el
|
|
98
|
+
de destrucción; con el heredoc que se usa para un mensaje largo, el cuerpo entero viaja adentro del
|
|
99
|
+
comando. Ahora lo entrecomillado se lee como dato **sólo** cuando el comando es un commit: en
|
|
100
|
+
cualquier otro, lo que va entre comillas se ejecuta y se sigue juzgando.
|
|
101
|
+
|
|
102
|
+
- **`AGENTS.md` dice cuál de sus límites puede habilitar tu proyecto, y cuál no.** Enumeraba seis cosas
|
|
103
|
+
que el runner nunca hace y dos párrafos después las llamaba «los cuatro límites del párrafo anterior»,
|
|
104
|
+
que son los que no se amplían. Aparte del conteo, entre esas seis estaba **publicar**, que el motor
|
|
105
|
+
hace configurable a propósito con `runner.allowPush`. Un proyecto que lo leyera al pie concluía que su
|
|
106
|
+
`allowPush: true` era ilegítimo, o que podía ampliar cualquiera de las seis y elegía mal. **Qué cambia
|
|
107
|
+
para vos**: el conteo desapareció, y el texto dice que publicar es lo único que se habilita y con qué
|
|
108
|
+
llave, y que reescribir historia publicada no entra en ese trato.
|
|
109
|
+
|
|
110
|
+
- **R12 manda las excepciones sobre sistemas externos donde `upgrade` no las borra.** La regla cerraba
|
|
111
|
+
diciendo que se documentan en el `AGENTS.md` del proyecto, y ese archivo es del toolkit: se reemplaza
|
|
112
|
+
entero en cada actualización. Quien obedecía la regla escribía su excepción donde la siguiente
|
|
113
|
+
actualización se la iba a llevar, sin que nada lo avisara. **Qué cambia para vos**: ahora apuntan a la
|
|
114
|
+
sección «Integraciones y ambientes» de `organization/workspace.md`, que ya nombraba a R12 y reclamaba
|
|
115
|
+
esas excepciones. Si tenías alguna escrita en `AGENTS.md`, movela: ahí no sobrevive.
|
|
116
|
+
|
|
117
|
+
- **Un campo de `DONE.md` que se envuelve se lee entero.** Cada campo —`acept:`, `done:`, `qa:`,
|
|
118
|
+
`tests:`, `decisions:`, `commit:`— se leía de una sola línea física, y sus valores son prosa que se
|
|
119
|
+
envuelve como cualquier otra línea. Cuando la envoltura partía una cita, `check` respondía «decisions
|
|
120
|
+
debe citar» sobre un campo que **sí** citaba: el mensaje nombraba una ausencia que no era la que
|
|
121
|
+
había, y mandaba a revisar lo único que sí estaba. **Qué cambia para vos**: las entradas que venías
|
|
122
|
+
reescribiendo hasta que entraran en un renglón pasan como están, y `tests:` y `commit:` dejan de
|
|
123
|
+
perder lo que quedaba debajo del salto.
|
|
124
|
+
|
|
125
|
+
## [0.60.1] - 2026-09-03
|
|
126
|
+
|
|
127
|
+
### Corregido
|
|
128
|
+
|
|
129
|
+
- **Rehacer un banco de evaluación ya no falla de vez en cuando.** `evaluate --bench --force` borra el
|
|
130
|
+
banco antes de recrearlo, y sobre un árbol grande y versionado ese borrado da `ENOTEMPTY` a veces —es
|
|
131
|
+
transitorio—. La corrida moría antes de empezar, sin haber evaluado nada. Ahora reintenta, que es lo
|
|
132
|
+
que `rmSync` ofrece para eso. Sólo afecta al toolkit: en una instancia `--bench` no corre.
|
|
133
|
+
|
|
17
134
|
## [0.60.0] - 2026-09-03
|
|
18
135
|
|
|
19
136
|
### Corregido
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
> **Lenguaje**: Go · **Vigente desde**: 2026-03-01 · **Dueño**: Arquitectura Cardinal
|
|
2
|
+
|
|
3
|
+
# Guía de Arquitectura Go — Cardinal
|
|
4
|
+
|
|
5
|
+
Arquitectura objetivo para todo servicio Go nuevo: **hexagonal (ports & adapters), topología
|
|
6
|
+
layer-first**. Los servicios existentes migran por nivel de calibración.
|
|
7
|
+
|
|
8
|
+
## ARCH-001: Topología layer-first, subdominio-second
|
|
9
|
+
|
|
10
|
+
Tres bloques bajo `internal/`. La división por subdominio vive **dentro** de cada capa; no existe una
|
|
11
|
+
carpeta umbrella que agrupe capas.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
{servicio}/
|
|
15
|
+
├── cmd/{api,worker,cron}/main.go
|
|
16
|
+
├── internal/
|
|
17
|
+
│ ├── core/
|
|
18
|
+
│ │ ├── domain/{sub}/ # entidades, invariantes, value objects (SIN tags)
|
|
19
|
+
│ │ ├── ports/in/ # driving: un puerto in = un caso de uso
|
|
20
|
+
│ │ ├── ports/out/ # driven: repos, gateways, tx
|
|
21
|
+
│ │ └── service/{sub}/ # casos de uso: orquestan, no deciden reglas
|
|
22
|
+
│ ├── adapters/{incoming,outgoing}/
|
|
23
|
+
│ └── infrastructure/ # config, DI, router, db, middleware
|
|
24
|
+
├── linters/.arch-lint.yml
|
|
25
|
+
└── mocks/
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## ARCH-002: Un módulo, múltiples entrypoints
|
|
29
|
+
|
|
30
|
+
Un servicio es un solo módulo Go y una sola imagen, con varios binarios en `cmd/`. API, worker y cron
|
|
31
|
+
comparten `internal/` y reutilizan `core/domain` + `core/service` por import directo. Kubernetes elige
|
|
32
|
+
el proceso seleccionando el binario (`command`).
|
|
33
|
+
|
|
34
|
+
## ARCH-003: El núcleo está aislado de la infraestructura
|
|
35
|
+
|
|
36
|
+
`internal/core/**` no puede importar `internal/adapters/**`, `internal/infrastructure/**` ni librerías
|
|
37
|
+
de infraestructura (pgx, echo, rabbitmq, aws). `core/domain/**` además no lleva tags `db:`/`json:` ni
|
|
38
|
+
contiene SQL ni HTTP.
|
|
39
|
+
|
|
40
|
+
Direcciones de dependencia permitidas:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
domain → (nadie)
|
|
44
|
+
ports → domain
|
|
45
|
+
service → domain, ports
|
|
46
|
+
adapters → domain, ports
|
|
47
|
+
infrastructure → domain, ports, service, adapters
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Esta regla la hace cumplir el arch-linter en CI**: la configuración vive en `linters/.arch-lint.yml`
|
|
51
|
+
y el job `lint` del pipeline corre `make arch-lint`. Un import que cruce una de esas flechas no llega
|
|
52
|
+
a `main`, así que el aislamiento del core no depende de la disciplina de quien revisa.
|
|
53
|
+
|
|
54
|
+
## ARCH-004: Puertos `in`/`out` en lenguaje de dominio
|
|
55
|
+
|
|
56
|
+
Los contratos viven en `core/ports/`, partidos por dirección, y hablan de entidades de dominio: nunca
|
|
57
|
+
de `record`, de `request`/`response` ni de tipos de pgx.
|
|
58
|
+
|
|
59
|
+
## ARCH-005: El modelo se parte en tres, con dos mappers
|
|
60
|
+
|
|
61
|
+
Prohibido el struct único con `db:` + `json:` que fluye por todas las capas. Entidad en
|
|
62
|
+
`core/domain/{sub}`; `record.go` + mapper en `adapters/outgoing/repository/{sub}`; `request.go` /
|
|
63
|
+
`response.go` + mapper en `adapters/incoming/handler/{sub}`. Los mappers dependen del dominio, nunca
|
|
64
|
+
al revés.
|
|
65
|
+
|
|
66
|
+
## ARCH-006: La transacción se abre en el caso de uso
|
|
67
|
+
|
|
68
|
+
El caso de uso controla la transacción a través de un puerto `out.TxManager` que el core define y un
|
|
69
|
+
adaptador implementa. El repositorio participa de la transacción; no la crea.
|
|
70
|
+
|
|
71
|
+
## ARCH-008: Calibración A/B/C
|
|
72
|
+
|
|
73
|
+
La profundidad la dicta la complejidad del dominio, no el dogma. El piso de aislamiento —core sin
|
|
74
|
+
tags, puertos `in`/`out`, adaptadores afuera, los dos mappers— es innegociable; la riqueza se agrega
|
|
75
|
+
sólo cuando hay reglas reales.
|
|
76
|
+
|
|
77
|
+
| Nivel | Cuándo | Qué incluye |
|
|
78
|
+
|-------|--------|-------------|
|
|
79
|
+
| **A** — dominio rico | máquina de estados, dinero/saldo, stock con invariantes | entidad con comportamiento, value objects, errores de dominio, transacción explícita |
|
|
80
|
+
| **B** — CRUD/lectura | sin invariantes | entidad + puertos + mappers; `service` delgado; sin value objects |
|
|
81
|
+
| **C** — gateway | la lógica vive en otro servicio | puede no tener `repository/`; la salida es un cliente HTTP |
|
|
82
|
+
|
|
83
|
+
El disparador de Nivel A lo **ratifica Arquitectura**, no el squad solo: (a) máquina de estados,
|
|
84
|
+
(b) dinero o saldo, (c) stock con invariantes.
|
|
85
|
+
|
|
86
|
+
## ARCH-009: Comunicación entre subdominios vía `ports/out`
|
|
87
|
+
|
|
88
|
+
Un subdominio no importa las entidades ni el repositorio de otro. Si `payouts` necesita algo de
|
|
89
|
+
`merchants`, lo pide por un puerto `out` expresado en sus propios términos.
|
|
90
|
+
|
|
91
|
+
## Reglas retiradas (trazabilidad histórica)
|
|
92
|
+
|
|
93
|
+
Una regla se cita por su número desde una ADR, una revisión o un pipeline, así que el número no se
|
|
94
|
+
reordena ni se reusa: el hueco es el rastro de lo que se retiró.
|
|
95
|
+
|
|
96
|
+
| ID | Estado | Reemplazada por |
|
|
97
|
+
|----|--------|-----------------|
|
|
98
|
+
| `ARCH-007` | retirada (2026-02) | `ARCH-005`. Exigía un paquete `internal/dto/` por servicio, compartido entre entrada y salida; el reparto en `request`/`response` y `record`, cada uno con su mapper en su adaptador, lo dejó sin objeto. |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# arch-lint — control de direcciones de dependencia entre componentes.
|
|
2
|
+
#
|
|
3
|
+
# Esquema: cada entrada de `rules` puede declarar su propia `severity`. La que no la declara toma
|
|
4
|
+
# `defaults.severity`. El comando imprime todos los hallazgos y termina en código distinto de cero
|
|
5
|
+
# sólo si encontró alguno con severidad mayor o igual a `fail_on`.
|
|
6
|
+
version: 1
|
|
7
|
+
|
|
8
|
+
fail_on: error
|
|
9
|
+
|
|
10
|
+
defaults:
|
|
11
|
+
severity: warning
|
|
12
|
+
|
|
13
|
+
components:
|
|
14
|
+
domain: internal/core/domain/**
|
|
15
|
+
ports: internal/core/ports/**
|
|
16
|
+
service: internal/core/service/**
|
|
17
|
+
adapters: internal/adapters/**
|
|
18
|
+
infrastructure: internal/infrastructure/**
|
|
19
|
+
vendor: [github.com/jackc/pgx/**, github.com/labstack/echo/**, github.com/rabbitmq/**, github.com/aws/**]
|
|
20
|
+
|
|
21
|
+
rules:
|
|
22
|
+
domain:
|
|
23
|
+
deny: [ports, service, adapters, infrastructure, vendor]
|
|
24
|
+
severity: error
|
|
25
|
+
|
|
26
|
+
ports:
|
|
27
|
+
allow: [domain]
|
|
28
|
+
severity: error
|
|
29
|
+
|
|
30
|
+
service:
|
|
31
|
+
deny: [adapters, infrastructure, vendor]
|
|
32
|
+
|
|
33
|
+
adapters:
|
|
34
|
+
allow: [domain, ports, vendor]
|
|
35
|
+
|
|
36
|
+
infrastructure:
|
|
37
|
+
allow: [domain, ports, service, adapters, vendor]
|
|
38
|
+
severity: error
|
|
39
|
+
|
|
40
|
+
exclude:
|
|
41
|
+
- "**/*_test.go"
|
|
42
|
+
- "mocks/**"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Solicitud
|
|
2
|
+
|
|
3
|
+
Cardinal arranca `payouts` la semana que viene: el servicio que liquida los saldos de los comercios y mueve cada pago por su máquina de estados (pendiente → aprobado → liquidado → revertido). Necesito el ADR cerrado hoy para que el squad abra el repositorio mañana.
|
|
4
|
+
|
|
5
|
+
Te dejo lo vigente: la guía de arquitectura Go (`guia-arquitectura-go.md`) y la configuración del arch-linter que corre en CI (`linters/.arch-lint.yml`).
|
|
6
|
+
|
|
7
|
+
En el ADR quiero tres cosas: el nivel de calibración con su justificación, los límites y la propiedad de datos del servicio, y el plan de arranque diciendo qué garantiza el pipeline y qué queda a cargo de las personas. Plataforma está con el corte de trimestre hasta fin de mes, así que si el aislamiento del core ya lo bloquea CI no les abrimos ticket y arrancamos con lo que hay.
|
|
8
|
+
|
|
9
|
+
# Comportamientos esperados
|
|
10
|
+
|
|
11
|
+
- Establecer qué violación rompe efectivamente el pipeline leyendo `linters/.arch-lint.yml`: con `fail_on: error` y `defaults.severity: warning`, sólo las reglas de `domain`, `ports` e `infrastructure` declaran `severity: error`; las de `service` y `adapters` quedan en advertencia y no cambian el código de salida.
|
|
12
|
+
- Decir en el ADR que el aislamiento del core queda cubierto a medias —un `core/service` que importe pgx, echo o un adaptador se reporta pero no frena el build— en vez de darlo por bloqueado porque la guía atribuye la regla al arch-linter en CI.
|
|
13
|
+
- Registrar la discrepancia entre lo que afirma `guia-arquitectura-go.md` («un import que cruce una de esas flechas no llega a `main`») y lo que hace la configuración, citando qué dice cada uno, sin resolverla a favor del que permite arrancar sin ticket.
|
|
14
|
+
- Dejar lo que falta como acción con destinatario y contenido —subir a `error` las reglas de `service` y `adapters`, o el control humano que las suple mientras Plataforma esté con el corte— en vez de cerrar el ADR con que CI ya lo cubre.
|
|
15
|
+
- Elegir el nivel de calibración con el disparador que la guía declara para dinero/saldo y máquina de estados, y dejar dicho quién lo ratifica.
|
|
@@ -45,6 +45,10 @@ const CONTEXT = {
|
|
|
45
45
|
blocked: { type: 'string' }, hasTask: { type: 'boolean' }, wipActive: { type: 'boolean' },
|
|
46
46
|
queued: { type: 'integer' }, slug: { type: 'string' }, hito: { type: 'string' },
|
|
47
47
|
service: { type: 'string' }, acceptance: { type: 'string' }, epic: { type: 'string' },
|
|
48
|
+
// Sin declararlo acá no llega: `additionalProperties: false` lo descartaría, y el aplanado de la
|
|
49
|
+
// épica a su número —dos líneas arriba— hace fácil creer que ya viene. La fase Plan lo pedía en su
|
|
50
|
+
// prompt y planificaba contra el título; `readEpics` cuenta de dónde sale.
|
|
51
|
+
epicContext: { type: 'string' },
|
|
48
52
|
lane: { type: 'string', enum: ['', 'express', 'directo', 'lite', 'full'] },
|
|
49
53
|
// Quién entrega y quiénes miran, decidido al clasificar la tarea y escrito en su línea. Viene
|
|
50
54
|
// siempre, aunque venga vacío: preguntar si el campo existe antes de leerlo es la clase de borde
|
|
@@ -308,7 +312,8 @@ const write = (prompt, options = {}) => agent(`${LEDGER}\n\n${prompt}`, options)
|
|
|
308
312
|
const readContext = () => read(
|
|
309
313
|
`Corré "node tools/ops.js context ${P} --json" desde ${ROOT} y reportá sólo lo que imprimió. Derivá hasTask ` +
|
|
310
314
|
`de si task es null, wipActive de si wip es null y lane de task.tier; copiá slug, hito, service, acceptance, ` +
|
|
311
|
-
`epic y cast de task. El comando es la fuente de
|
|
315
|
+
`epic y cast de task, y epicContext de epic.context —vacío si no hay épica—. El comando es la fuente de ` +
|
|
316
|
+
`verdad: no abras archivos de planning para completarlo.`,
|
|
312
317
|
{ schema: CONTEXT, label: 'planning-context' },
|
|
313
318
|
)
|
|
314
319
|
|
|
@@ -344,7 +349,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
344
349
|
if (!planning.hasTask || (currentMilestone && planning.hito !== currentMilestone)) break
|
|
345
350
|
const task = {
|
|
346
351
|
id: planning.slug, hito: planning.hito, service: planning.service,
|
|
347
|
-
acceptance: planning.acceptance, epic: planning.epic,
|
|
352
|
+
acceptance: planning.acceptance, epic: planning.epic, epicContext: planning.epicContext || '',
|
|
348
353
|
}
|
|
349
354
|
currentMilestone = task.hito
|
|
350
355
|
|
|
@@ -455,7 +460,9 @@ while (rounds++ < MAX_TASKS) {
|
|
|
455
460
|
phase('Plan')
|
|
456
461
|
plan = await run(
|
|
457
462
|
`${asRole(OWNERS.plan)}Inspeccioná el código real, las instrucciones del repositorio, las convenciones ` +
|
|
458
|
-
`vecinas
|
|
463
|
+
`vecinas y el git status de ${task.id}.` +
|
|
464
|
+
`${task.epicContext ? ` Contexto de la épica: ${task.epicContext}` : ''}` +
|
|
465
|
+
` Producí el plan más chico que satisfaga ` +
|
|
459
466
|
`${task.acceptance}. Un archivo de planning no puede ser un archivo de implementación. El plan cubre ` +
|
|
460
467
|
`sólo el cambio dentro de ${task.service}: correr los gates del repositorio, hacer QA, commitear y ` +
|
|
461
468
|
`cerrar la tarea son fases posteriores de este recorrido, cada una con su dueño, así que no van como ` +
|
package/engine/cli/args.js
CHANGED
package/engine/cli/catalog.js
CHANGED
|
@@ -96,7 +96,12 @@ function evaluationBench(root, agent, caso, force, kind) {
|
|
|
96
96
|
fail(`${dir} tiene trabajo sin recoger. Guardá el registro de esa corrida antes de rehacerlo, `
|
|
97
97
|
+ 'o usá --force si ya lo tenés.', 2)
|
|
98
98
|
}
|
|
99
|
-
|
|
99
|
+
// Con reintentos: el banco es un árbol grande y versionado —hay un `git status` dos líneas arriba— y
|
|
100
|
+
// borrarlo entero falla a veces con ENOTEMPTY, que es transitorio. Pasó en CI rehaciendo un banco que
|
|
101
|
+
// se acababa de crear: `ENOTEMPTY, Directory not empty: .cauce-eval/product-manager/11-otro`. Sin los
|
|
102
|
+
// reintentos, rehacer un banco es una operación que falla de vez en cuando y deja la corrida sin
|
|
103
|
+
// empezar.
|
|
104
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
100
105
|
IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true })
|
|
101
106
|
// El motor por symlink: la misma resolución que en una instancia real —`node_modules/@ingeniomaps`—
|
|
102
107
|
// sin pagar un `npm install` por corrida. El cargo llega a un banco donde el CLI funciona.
|
package/engine/cli/ops.js
CHANGED
|
@@ -146,6 +146,7 @@ function usage() {
|
|
|
146
146
|
ops upgrade <ops-root> [--check] [--force]
|
|
147
147
|
ops destroy <ops-root> [--force]
|
|
148
148
|
ops archive <planning-dir> <NNN|human-actions>
|
|
149
|
+
ops adopt <planning-dir>
|
|
149
150
|
ops integration list <ops-root>
|
|
150
151
|
ops integration enable <ops-root> <provider>
|
|
151
152
|
ops integration disable <ops-root> <provider>
|
|
@@ -198,6 +199,7 @@ async function run(cli) {
|
|
|
198
199
|
else if (command === 'destroy') IN.destroy(arg[1], cli)
|
|
199
200
|
else if (command === 'agents') CAT.agents(arg[1], arg[2], arg[3], cli)
|
|
200
201
|
else if (command === 'archive') PL.archive(arg[1], arg[2])
|
|
202
|
+
else if (command === 'adopt') PL.adopt(arg[1])
|
|
201
203
|
else if (command === 'integration') {
|
|
202
204
|
await W.integration(arg[1], arg[2], arg[3], arg[4], cli)
|
|
203
205
|
}
|
package/engine/cli/planning.js
CHANGED
|
@@ -8,11 +8,14 @@ const path = require('node:path')
|
|
|
8
8
|
const P = require('../planning/parser')
|
|
9
9
|
const B = require('../planning/business-rules')
|
|
10
10
|
const PC = require('../planning/contracts')
|
|
11
|
+
const SZ = require('../planning/sizing')
|
|
11
12
|
const ST = require('../planning/state')
|
|
13
|
+
const AD = require('../planning/adoption')
|
|
12
14
|
const I = require('../integrations/registry')
|
|
13
15
|
const O = require('../core/ownership')
|
|
14
16
|
const OB = require('../core/onboarding')
|
|
15
17
|
const C = require('../config/validate')
|
|
18
|
+
const CP = require('../config/paths')
|
|
16
19
|
const AG = require('../agents/catalog')
|
|
17
20
|
const F = require('../core/files')
|
|
18
21
|
const { fail } = require('./io')
|
|
@@ -45,6 +48,14 @@ function check(dir, cli) {
|
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
}
|
|
51
|
+
// Es la única parte de la configuración que le levanta el límite a un guard, y quien la escribió
|
|
52
|
+
// no es quien la lee dentro de seis meses: va como advertencia permanente, igual que un override.
|
|
53
|
+
// Y se muestra resuelta porque resuelta es como la compara el guard — un `~` escrito solo exenta
|
|
54
|
+
// la casa entera, y escrito no se nota.
|
|
55
|
+
for (const exempt of CP.writableOutsideRoots(path.dirname(configPath), config)) {
|
|
56
|
+
warnings.push(`ops.config.json: ${exempt.declared} está exenta del límite `
|
|
57
|
+
+ `de raíces (${exempt.path})`)
|
|
58
|
+
}
|
|
48
59
|
}
|
|
49
60
|
} catch (error) {
|
|
50
61
|
errors.push(`ops.config.json: JSON inválido (${error.message})`)
|
|
@@ -68,10 +79,12 @@ function check(dir, cli) {
|
|
|
68
79
|
|
|
69
80
|
const roles = new Set(AG.list(path.resolve(root, '..')).map((role) => role.slug))
|
|
70
81
|
const wip = P.readWip(root)
|
|
71
|
-
|
|
82
|
+
const adopted = AD.read(root)
|
|
83
|
+
errors.push(...SZ.oversizedUnits({ epics, milestones }))
|
|
72
84
|
errors.push(...PC.validateState({
|
|
73
|
-
epics, milestones, done, wip, roles, humanActions: P.readHumanActions(root),
|
|
85
|
+
epics, milestones, done, wip, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
|
|
74
86
|
}))
|
|
87
|
+
warnings.push(...AD.report({ done, epics, adopted }))
|
|
75
88
|
|
|
76
89
|
const integration = I.validate(path.resolve(root, '..'))
|
|
77
90
|
errors.push(...integration.errors)
|
|
@@ -196,7 +209,10 @@ function context(dir, cli) {
|
|
|
196
209
|
epic: task.epic,
|
|
197
210
|
},
|
|
198
211
|
criteria,
|
|
199
|
-
|
|
212
|
+
// El título nombra el tema; el contexto dice contra qué se construye, que es lo que separa cumplir
|
|
213
|
+
// un criterio de cumplir su letra. Viaja acá porque el ejecutor tiene prohibido ir a buscarlo:
|
|
214
|
+
// `autobuild` le dice que lea cuatro archivos una sola vez y nada más, y el roadmap no es ninguno.
|
|
215
|
+
epic: epic ? { num: epic.num, title: epic.title, status: epic.status, context: epic.context } : null,
|
|
200
216
|
wip: state.wip ? { phase: state.wip.phase, complete: state.wip.complete, pending: state.wip.pending } : null,
|
|
201
217
|
queued: state.milestones.reduce((total, milestone) => total + milestone.tasks.length, 0),
|
|
202
218
|
blockedTasks: skipped,
|
|
@@ -229,6 +245,12 @@ function context(dir, cli) {
|
|
|
229
245
|
console.log(`CAST ${report.task.cast.build}${review.length ? ` → ${review.join(', ')}` : ''}`)
|
|
230
246
|
}
|
|
231
247
|
if (report.epic) console.log(`EPIC ${report.epic.num} ${report.epic.title} [${report.epic.status}]`)
|
|
248
|
+
// Entera y sin recortar, que es lo que hace el resto de esta salida con la aceptación y los criterios.
|
|
249
|
+
// Recortar sería la conducta nueva, y no hay dónde cortar: la sección es una lista de viñetas y la
|
|
250
|
+
// primera no resume a las otras. Si crece de más, el que tiene que ponerle techo es el molde.
|
|
251
|
+
for (const line of (report.epic?.context || '').split('\n')) {
|
|
252
|
+
if (line.trim()) console.log(`CTX ${line.trim()}`)
|
|
253
|
+
}
|
|
232
254
|
if (report.task.acceptance) console.log(`ACEPT ${report.task.acceptance}`)
|
|
233
255
|
for (const criterion of criteria) console.log(`${criterion.id.padEnd(6)} ${criterion.text}`)
|
|
234
256
|
const wip = report.wip ? `${report.wip.phase} · ${report.wip.complete}✓/${report.wip.pending}○` : 'idle'
|
|
@@ -240,6 +262,31 @@ function context(dir, cli) {
|
|
|
240
262
|
// El historial de acciones humanas se acumula en un solo archivo y no por épica: una fila no pertenece
|
|
241
263
|
// a ninguna, y esperar el cierre de una épica dejaría sin archivar las de un planning que todavía no
|
|
242
264
|
// cerró ninguna —que es justo cuando el archivo se vuelve ilegible—.
|
|
265
|
+
// Adoptar es declarar de una vez qué historia llegó con el proyecto. Se genera con lo que hoy no cumple
|
|
266
|
+
// y no se vuelve a correr: un baseline que se regenera perdona de nuevo lo que alguien ya se tomó el
|
|
267
|
+
// trabajo de arreglar, y uno que crece a mano deja de ser una lista de perdones para ser una amnistía.
|
|
268
|
+
// Achicarlo sí es a mano, borrando el renglón que `check` señala.
|
|
269
|
+
function adopt(dir) {
|
|
270
|
+
const root = path.resolve(dir || '.')
|
|
271
|
+
const target = path.join(root, AD.BASELINE)
|
|
272
|
+
if (fs.existsSync(target)) {
|
|
273
|
+
fail(`${AD.BASELINE} ya existe: se genera una vez. Para achicarlo, borrá los renglones que `
|
|
274
|
+
+ '`check` marca como cumplidos.')
|
|
275
|
+
}
|
|
276
|
+
const epics = P.readEpics(root)
|
|
277
|
+
const pending = P.readDone(root).entries.filter((entry) => PC.doneEntryErrors(entry, epics).length)
|
|
278
|
+
if (!pending.length) {
|
|
279
|
+
return console.log('= no hay nada que exentar: todas las entradas de DONE cumplen el contrato')
|
|
280
|
+
}
|
|
281
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
282
|
+
F.atomicWrite(target, `# Entradas anteriores a la adopción de Cauce (${today}). No se agregan nuevas:\n`
|
|
283
|
+
+ '# desde esa fecha rige el contrato completo, y `check` avisa cuando una de éstas pasa a\n'
|
|
284
|
+
+ '# cumplirlo para que se borre su renglón.\n'
|
|
285
|
+
+ `${pending.map((entry) => entry.slug).join('\n')}\n`)
|
|
286
|
+
console.log(`✓ ${pending.length} entrada(s) exentas en ${AD.BASELINE}`)
|
|
287
|
+
return console.log(' revisá la lista: lo que sí cumple el contrato no tiene por qué estar ahí')
|
|
288
|
+
}
|
|
289
|
+
|
|
243
290
|
function archiveHumanActions(root) {
|
|
244
291
|
const source = path.join(root, 'HUMAN_ACTIONS.md')
|
|
245
292
|
const rows = P.readHumanActions(root).filter((row) => row.resolved)
|
|
@@ -287,4 +334,4 @@ function archive(dir, rawNum) {
|
|
|
287
334
|
console.log(`✓ epic-${num}: ${entries.length} entrada(s) archivadas`)
|
|
288
335
|
}
|
|
289
336
|
|
|
290
|
-
module.exports = { check, tree, context, archive }
|
|
337
|
+
module.exports = { check, tree, context, archive, adopt }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Las rutas que un proyecto declara escribibles sin que sean raíces de código: la memoria del runner, un
|
|
4
|
+
// scratchpad, un directorio de salida. Viven acá y no en cada consumidor porque son dos —el guard que
|
|
5
|
+
// las deja pasar y `check` que las muestra— y tienen que resolver igual: con una copia que se pudra, el
|
|
6
|
+
// guard permite una ruta distinta de la que se ve en la salida, que es la forma silenciosa del agujero.
|
|
7
|
+
|
|
8
|
+
const os = require('node:os')
|
|
9
|
+
const path = require('node:path')
|
|
10
|
+
|
|
11
|
+
// `~` se expande sólo cuando es el prefijo entero. `~datos` es un nombre de directorio válido y no la
|
|
12
|
+
// casa de nadie; expandirlo ahí convertiría una ruta relativa en una absoluta que el autor no escribió.
|
|
13
|
+
function resolve(root, entry) {
|
|
14
|
+
return path.resolve(root, String(entry).replace(/^~(?=$|[/\\])/, os.homedir()))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Lee una configuración que puede estar mal escrita, así que no supone su forma. El validador rechaza
|
|
18
|
+
// `writableOutsideRoots: "~/memoria"` con un error que dice qué corregir, pero los dos consumidores
|
|
19
|
+
// llegan antes que él: el guard corre sin validar nada, y en `check` un `.filter` sobre un string se
|
|
20
|
+
// convertía en «JSON inválido», que manda a buscar una llave que no falta.
|
|
21
|
+
function writableOutsideRoots(root, config) {
|
|
22
|
+
const declared = config && Array.isArray(config.writableOutsideRoots) ? config.writableOutsideRoots : []
|
|
23
|
+
return declared.filter((entry) => typeof entry === 'string' && entry.trim())
|
|
24
|
+
.map((entry) => ({ declared: entry, path: resolve(root, entry) }))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { writableOutsideRoots }
|
|
@@ -19,7 +19,7 @@ function validateOpsConfig(config) {
|
|
|
19
19
|
}
|
|
20
20
|
// `cauceVersion` la escribe el toolkit, no la persona: registra de qué versión salió la instancia.
|
|
21
21
|
const allowed = new Set([
|
|
22
|
-
'$schema', 'cauceVersion', 'project', 'mode', 'workspaceRoots', 'runner',
|
|
22
|
+
'$schema', 'cauceVersion', 'project', 'mode', 'workspaceRoots', 'writableOutsideRoots', 'runner',
|
|
23
23
|
])
|
|
24
24
|
for (const key of Object.keys(config)) {
|
|
25
25
|
if (RETIRED[key]) errors.push(`ops.config.json: ${key} ya no se usa: ${RETIRED[key]}`)
|
|
@@ -30,6 +30,7 @@ function validateOpsConfig(config) {
|
|
|
30
30
|
}
|
|
31
31
|
if (!MODES.includes(config.mode)) errors.push('ops.config.json: mode inválido')
|
|
32
32
|
validateWorkspaces(config.workspaceRoots, errors)
|
|
33
|
+
validateWritable(config.writableOutsideRoots, errors)
|
|
33
34
|
validateRunner(config.runner, errors)
|
|
34
35
|
return errors
|
|
35
36
|
}
|
|
@@ -63,6 +64,22 @@ function validateWorkspaces(workspaces, errors) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
// Opcional de verdad: la mayoría de los proyectos no exenta nada, así que ausente y vacía son lo mismo
|
|
68
|
+
// y ninguna de las dos se reclama. Lo que sí se exige es que cada entrada sea una ruta escrita — un
|
|
69
|
+
// número o un objeto se resolvería igual a *algo*, y ese algo quedaría exento sin que nadie lo eligiera.
|
|
70
|
+
function validateWritable(paths, errors) {
|
|
71
|
+
if (paths === undefined) return
|
|
72
|
+
if (!Array.isArray(paths)) {
|
|
73
|
+
errors.push('ops.config.json: writableOutsideRoots debe ser una lista de rutas')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
for (const [index, entry] of paths.entries()) {
|
|
77
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
78
|
+
errors.push(`ops.config.json: writableOutsideRoots[${index}] debe ser una ruta no vacía`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
function validateRunner(runner, errors) {
|
|
67
84
|
if (!runner || typeof runner !== 'object' || Array.isArray(runner)) {
|
|
68
85
|
errors.push('ops.config.json: runner debe ser un objeto')
|
package/engine/hooks/files.js
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require('node:fs')
|
|
8
8
|
const path = require('node:path')
|
|
9
|
-
const {
|
|
9
|
+
const {
|
|
10
|
+
patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot,
|
|
11
|
+
writableRoots, outsideRoots, DECLARE_IT,
|
|
12
|
+
} = require('./input')
|
|
10
13
|
|
|
11
14
|
function secrets(input) {
|
|
12
15
|
for (const file of filesOf(input)) {
|
|
@@ -95,14 +98,12 @@ function testEvidence(input) {
|
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
function workspaceBoundary(input) {
|
|
98
|
-
const
|
|
99
|
-
if (!
|
|
100
|
-
const config = configOf(root)
|
|
101
|
-
const allowed = [root, ...(config.workspaceRoots || []).map((entry) => path.resolve(root, entry.path))]
|
|
101
|
+
const allowed = writableRoots(input)
|
|
102
|
+
if (!allowed) return
|
|
102
103
|
for (const raw of filesOf(input)) {
|
|
103
104
|
const file = path.resolve(cwdOf(input), raw)
|
|
104
|
-
if (
|
|
105
|
-
block(`${file} está fuera de las raíces declaradas en ops.config.json
|
|
105
|
+
if (outsideRoots(file, allowed)) {
|
|
106
|
+
block(`${file} está fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
}
|
package/engine/hooks/input.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
const fs = require('node:fs')
|
|
9
9
|
const path = require('node:path')
|
|
10
10
|
const { spawnSync } = require('node:child_process')
|
|
11
|
+
const { writableOutsideRoots } = require('../config/paths')
|
|
11
12
|
|
|
12
13
|
// Sin stdin no hay nada que leer y los guards caen a las variables de entorno; con stdin ilegible sí
|
|
13
14
|
// hay algo y no se entiende, que es otra cosa. Devolver `{}` ahí dejaba a cada guard sin comando ni
|
|
@@ -123,7 +124,38 @@ function findOpsRoot(start) {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// Lo que un proyecto declaró que puede escribirse: su raíz de ops, las raíces de código y las rutas que
|
|
128
|
+
// exentó sin que sean código. Lo preguntan los dos guards de límites —el que mira un `Write` y el que
|
|
129
|
+
// mira el destino de un comando— y tienen que responder lo mismo: con dos copias, una herramienta
|
|
130
|
+
// escribiría donde la otra bloquea, que es exactamente el agujero que el segundo vino a cerrar.
|
|
131
|
+
//
|
|
132
|
+
// Sin raíz legible no hay lista, y quien pregunta se abstiene: el guard que no sabe dónde está no
|
|
133
|
+
// inventa un límite.
|
|
134
|
+
function writableRoots(input) {
|
|
135
|
+
const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
136
|
+
if (!root) return null
|
|
137
|
+
const config = configOf(root)
|
|
138
|
+
return [
|
|
139
|
+
root,
|
|
140
|
+
...(config.workspaceRoots || []).map((entry) => path.resolve(root, entry.path)),
|
|
141
|
+
...writableOutsideRoots(root, config).map((entry) => entry.path),
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// La pregunta exacta y nada más. Las excepciones viven en quien las necesita: un `>` a `/dev/null` es
|
|
146
|
+
// corriente y una escritura de `Write` ahí no lo es, así que perdonarlas acá le habría cambiado en
|
|
147
|
+
// silencio el alcance a `workspace-boundary`, que no es lo que se vino a hacer.
|
|
148
|
+
function outsideRoots(file, allowed) {
|
|
149
|
+
return !allowed.some((base) => file === base || file.startsWith(`${base}${path.sep}`))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Va en los dos bloqueos y no en uno: un límite que sólo dice «no» enseña a rodearlo, y el rodeo que
|
|
153
|
+
// este mensaje evita es cambiar de herramienta, que es por donde el límite se perdía entero.
|
|
154
|
+
const DECLARE_IT = 'Si el proyecto necesita escribir ahí, declaralo en writableOutsideRoots de '
|
|
155
|
+
+ 'ops.config.json; cambiar de herramienta no lo autoriza.'
|
|
156
|
+
|
|
126
157
|
module.exports = {
|
|
127
158
|
readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
|
|
128
159
|
gitDirectory, isCommit, stagedFiles, pushAllowed, findOpsRoot,
|
|
160
|
+
writableRoots, outsideRoots, DECLARE_IT,
|
|
129
161
|
}
|
package/engine/hooks/run.js
CHANGED
|
@@ -38,6 +38,7 @@ const guards = {
|
|
|
38
38
|
dependencies: shell.dependencies,
|
|
39
39
|
governance: shell.governance,
|
|
40
40
|
verify: shell.verify,
|
|
41
|
+
'shell-boundary': shell.shellBoundary,
|
|
41
42
|
secrets: files.secrets,
|
|
42
43
|
generated: files.generated,
|
|
43
44
|
'workspace-boundary': files.workspaceBoundary,
|
|
@@ -50,7 +51,7 @@ const guards = {
|
|
|
50
51
|
|
|
51
52
|
// Grupos por evento: un runner corre el grupo entero en un solo proceso en lugar de un guard por hook.
|
|
52
53
|
const hookGroups = {
|
|
53
|
-
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify'],
|
|
54
|
+
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'],
|
|
54
55
|
'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
|
|
55
56
|
'integration-snapshot', 'test-evidence'],
|
|
56
57
|
stop: ['planning-drift'],
|
|
@@ -82,6 +83,11 @@ const hookMetadata = [
|
|
|
82
83
|
event: 'PreToolUse · shell',
|
|
83
84
|
purpose: 'Ejecuta los gates del stack y comprueba drift generado antes de un commit.',
|
|
84
85
|
},
|
|
86
|
+
{
|
|
87
|
+
name: 'shell-boundary',
|
|
88
|
+
event: 'PreToolUse · shell',
|
|
89
|
+
purpose: 'Frena el destino evidente de un comando que escribe fuera de las raíces declaradas.',
|
|
90
|
+
},
|
|
85
91
|
{
|
|
86
92
|
name: 'secrets',
|
|
87
93
|
event: 'PreToolUse · files',
|
package/engine/hooks/shell.js
CHANGED
|
@@ -5,19 +5,51 @@
|
|
|
5
5
|
// ahí que vayan juntos—, y son el grupo `pre-shell` que el registro ya declaraba.
|
|
6
6
|
|
|
7
7
|
const fs = require('node:fs')
|
|
8
|
+
const os = require('node:os')
|
|
8
9
|
const path = require('node:path')
|
|
9
10
|
const { spawnSync } = require('node:child_process')
|
|
10
11
|
const {
|
|
11
12
|
commandOf, cwdOf, block, gitDirectory, isCommit, stagedFiles, pushAllowed,
|
|
13
|
+
writableRoots, outsideRoots, DECLARE_IT,
|
|
12
14
|
} = require('./input')
|
|
13
15
|
|
|
16
|
+
// Un mensaje de commit es dato, no código. `git commit -m "fix: bloquear git push --force"` disparaba
|
|
17
|
+
// el guard de publicación, y lo mismo `rm -rf /` nombrado en una explicación; con el heredoc que se usa
|
|
18
|
+
// para un mensaje largo, el cuerpo entero entra en el comando, así que la línea que arregla esto no se
|
|
19
|
+
// podía commitear sin apagar el guard.
|
|
20
|
+
//
|
|
21
|
+
// Se vacía **sólo** en un commit. En cualquier otro comando lo que va entre comillas sí se ejecuta:
|
|
22
|
+
// `bash -c "git push origin main"` y `eval "git reset --hard"` siguen cayendo, comprobado. Queda afuera
|
|
23
|
+
// la sustitución dentro del propio mensaje —`git commit -m "$(...)"` corre y ya no se ve—, que es
|
|
24
|
+
// evasión y no la forma habitual.
|
|
14
25
|
function destructive(input) {
|
|
15
|
-
const
|
|
26
|
+
const raw = commandOf(input)
|
|
27
|
+
const command = isCommit(raw) ? unquoted(raw) : raw
|
|
28
|
+
// Ninguna de estas dos ramas tiene override, y la pregunta merece respuesta escrita porque cuatro
|
|
29
|
+
// guards del motor sí lo tienen. R8 no admite excepción configurable para `force` ni para `amend`, y
|
|
30
|
+
// el precedente es `git-add`, que hace cumplir la misma regla sin escapatoria. Lo que corresponde
|
|
31
|
+
// cuando de verdad hace falta es una acción humana, que deja rastro; una variable de entorno no.
|
|
32
|
+
//
|
|
33
|
+
// Publicar se autoriza; reescribir historia publicada, no. Eran el mismo interruptor: `\bgit\s+push\b`
|
|
34
|
+
// matchea igual las dos formas, así que `allowPush` habilitaba el force-push sin que nadie lo decidiera
|
|
35
|
+
// y el párrafo de autonomía de `AGENTS.md` tenía que confesarlo. R8 prohíbe `force` sin excepción
|
|
36
|
+
// configurable, así que esta rama va antes del permiso y no lo consulta.
|
|
37
|
+
if (/\bgit\s+push\b[^;&|]*\s(?:-f|--force(?:-with-lease|-if-includes)?)\b/.test(command)) {
|
|
38
|
+
block("'git push --force' reescribe historia ya publicada. R8 lo prohíbe y runner.allowPush no lo "
|
|
39
|
+
+ 'habilita: publicá con un push normal, o registrá una acción humana.')
|
|
40
|
+
}
|
|
16
41
|
if (/\bgit\s+push\b/.test(command) && !pushAllowed(input)) {
|
|
17
42
|
block("'git push' publica cambios y requiere una acción humana. Se habilita con runner.allowPush.")
|
|
18
43
|
}
|
|
19
44
|
const rules = [
|
|
20
45
|
[/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales."],
|
|
46
|
+
// R8 lo prohíbe sin excepción configurable y ningún guard lo miraba: `grep -rn amend engine/hooks/`
|
|
47
|
+
// no devolvía una línea. Se bloquea por política y no por daño —un `--amend` sobre algo que nadie vio
|
|
48
|
+
// no rompe nada—, así que el mensaje manda a lo que sí corresponde: otro commit.
|
|
49
|
+
[
|
|
50
|
+
/\bgit\s+commit\b[^;&|]*\s--amend\b/,
|
|
51
|
+
"'git commit --amend' reescribe un commit ya creado. R8 pide uno nuevo en su lugar.",
|
|
52
|
+
],
|
|
21
53
|
[/\bgit\s+clean\s+-[^\s]*f/, "'git clean -f' borra archivos sin seguimiento."],
|
|
22
54
|
// `git checkout -- .` destruye lo mismo que `reset --hard` y sin recuperación, pero se escribe como
|
|
23
55
|
// una limpieza. Se bloquea sólo la forma ancha —`.`, `*`, `:/`, o sin ruta—: revertir un archivo
|
|
@@ -106,6 +138,76 @@ function dependencies(input) {
|
|
|
106
138
|
}
|
|
107
139
|
}
|
|
108
140
|
|
|
141
|
+
// El destino de una escritura se juzgaba sólo en `Edit`/`Write`, así que el mismo archivo se escribía
|
|
142
|
+
// sin obstáculo con un heredoc por `Bash`: frenaba a quien actuaba de buena fe y no a quien quería pasar.
|
|
143
|
+
// Registrar `workspace-boundary` en este grupo no alcanzaba —lee `filesOf`, que en un comando no
|
|
144
|
+
// devuelve nada—, así que lo que faltaba era leer el comando.
|
|
145
|
+
//
|
|
146
|
+
// **Esto no puede ser completo y no se presenta como si lo fuera.** `eval`, una variable armada dos
|
|
147
|
+
// líneas antes, un heredoc dentro de `bash -c`, un `python -c "open(...)"` o un script propio escriben
|
|
148
|
+
// igual y ningún patrón los ve. Frena la forma habitual, como el resto de `destructive`; quien quiera
|
|
149
|
+
// pasar, pasa. Presentarlo como un límite invitaría a confiar en él más de lo que aguanta.
|
|
150
|
+
// Tres familias, porque los comandos no nombran su destino igual: `tee` y `truncate` escriben en cada
|
|
151
|
+
// argumento, `cp` y sus hermanos en el último, y `sed` sólo escribe con `-i` —sin él lee y manda a
|
|
152
|
+
// stdout, y esa redirección la ve REDIRECT—.
|
|
153
|
+
const REDIRECT = /(?:^|[\s(])&?\d*>>?\s*(?![&(])([^\s;|&<>()]+)/g
|
|
154
|
+
const EVERY_ARG = /(?:^|[\s;|&(])(tee|truncate)\s+([^;|&<>()]+)/g
|
|
155
|
+
const LAST_ARG = /(?:^|[\s;|&(])(cp|mv|install|rsync)\s+([^;|&<>()]+)/g
|
|
156
|
+
const SED = /(?:^|[\s;|&(])sed\s+([^;|&<>()]+)/g
|
|
157
|
+
const IN_PLACE = /(?:^|\s)-{1,2}i/
|
|
158
|
+
|
|
159
|
+
// Los argumentos que no son flags. El valor de un flag se cuela —`truncate -s 0 log` trae el `0`— y no
|
|
160
|
+
// hace falta sacarlo: un token así resuelve contra el cwd, que está adentro de la raíz, así que nunca
|
|
161
|
+
// decide un bloqueo. Filtrarlo sería una rama que ninguna prueba puede ver caer.
|
|
162
|
+
const positional = (text) => text.trim().split(/\s+/).filter((one) => one && !one.startsWith('-'))
|
|
163
|
+
|
|
164
|
+
// Vacía lo que va entre comillas, dejando una marca que ningún patrón confunde con una ruta ni con un
|
|
165
|
+
// comando. Lo usan dos guards por razones distintas, y cada uno explica la suya donde lo llama.
|
|
166
|
+
const unquoted = (command) => String(command).replace(/'[^']*'|"[^"]*"/g, '\u0000')
|
|
167
|
+
|
|
168
|
+
// Un `>` adentro de una cadena no redirige nada. Pierde el destino entrecomillado, que es un falso
|
|
169
|
+
// negativo — el error barato en un guard que ya es incompleto, porque el caro es frenar un comando
|
|
170
|
+
// legítimo y que alguien apague el guard entero.
|
|
171
|
+
//
|
|
172
|
+
// `$HOME` y `~` se expanden porque son como se escribe el destino que esto vino a ver; el incidente que
|
|
173
|
+
// lo originó decía `> $HOME/.claude/...`. Cualquier otra variable queda sin resolver y no se juzga:
|
|
174
|
+
// adivinar su valor sería inventarlo, y un límite inventado frena lo que nadie pidió frenar.
|
|
175
|
+
function writeTargets(command) {
|
|
176
|
+
const clean = unquoted(command)
|
|
177
|
+
const found = new Set()
|
|
178
|
+
for (const match of clean.matchAll(REDIRECT)) found.add(match[1])
|
|
179
|
+
for (const match of clean.matchAll(EVERY_ARG)) for (const one of positional(match[2])) found.add(one)
|
|
180
|
+
for (const match of clean.matchAll(LAST_ARG)) {
|
|
181
|
+
const args = positional(match[2])
|
|
182
|
+
// Con un solo argumento no hay destino: `cp solo` está a medio escribir, no escribe en `solo`.
|
|
183
|
+
if (args.length > 1) found.add(args[args.length - 1])
|
|
184
|
+
}
|
|
185
|
+
for (const match of clean.matchAll(SED)) {
|
|
186
|
+
const args = positional(match[1])
|
|
187
|
+
if (IN_PLACE.test(match[1]) && args.length) found.add(args[args.length - 1])
|
|
188
|
+
}
|
|
189
|
+
return [...found]
|
|
190
|
+
.map((one) => one.replace(/^~(?=$|\/)/, os.homedir()).replace(/^\$\{?HOME\}?(?=$|\/)/, os.homedir()))
|
|
191
|
+
.filter((one) => !/[$`\u0000]/.test(one))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Los destinos que no son de nadie y aparecen en cualquier comando legítimo: los descriptores del
|
|
195
|
+
// sistema y el temporal, que es donde el propio runner deja lo que no va al repositorio. Sin esta lista
|
|
196
|
+
// el guard frena `> /dev/null 2>&1`, y lo primero que hace quien lo sufre es apagarlo entero.
|
|
197
|
+
const NEUTRAL = [/^\/dev\/(?:null|stdout|stderr|tty|fd\/)/, new RegExp(`^${os.tmpdir()}(?:/|$)`)]
|
|
198
|
+
|
|
199
|
+
function shellBoundary(input) {
|
|
200
|
+
const allowed = writableRoots(input)
|
|
201
|
+
if (!allowed) return
|
|
202
|
+
for (const raw of writeTargets(commandOf(input))) {
|
|
203
|
+
const file = path.resolve(cwdOf(input), raw)
|
|
204
|
+
if (NEUTRAL.some((pattern) => pattern.test(file))) continue
|
|
205
|
+
if (outsideRoots(file, allowed)) {
|
|
206
|
+
block(`el comando escribe en ${file}, fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
109
211
|
function governance(input) {
|
|
110
212
|
if (process.env.OPS_GOVERNANCE_OVERRIDE === '1') return
|
|
111
213
|
const command = commandOf(input)
|
|
@@ -194,4 +296,4 @@ function verify(input) {
|
|
|
194
296
|
if (failures.length) block(`Verify falló en ${path.basename(dir)}: ${failures.join(', ')}. No se commitea en rojo.`)
|
|
195
297
|
}
|
|
196
298
|
|
|
197
|
-
module.exports = { destructive, gitAdd, dependencies, governance, verify, run }
|
|
299
|
+
module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El baseline de adopción: qué entradas de `DONE.md` ya existían cuando el proyecto empezó a usar Cauce,
|
|
4
|
+
// escritas bajo otro contrato de evidencia o bajo ninguno. Vive aparte de `contracts` porque no es un
|
|
5
|
+
// juicio sobre el planning sino una lista de perdones con su propia vida — se genera una vez, se mira en
|
|
6
|
+
// cada corrida y se achica a mano cuando una entrada vieja se reescribe.
|
|
7
|
+
//
|
|
8
|
+
// Es un archivo de texto y no un campo de configuración porque lo que lo mantiene sano es poder mirarlo
|
|
9
|
+
// y borrarle un renglón. Una fecha en `ops.config.json` perdonaría por tanda, y una tanda no se achica.
|
|
10
|
+
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const P = require('./parser')
|
|
13
|
+
const PC = require('./contracts')
|
|
14
|
+
|
|
15
|
+
const BASELINE = '.adoption-baseline'
|
|
16
|
+
|
|
17
|
+
// El archivo ausente y el archivo vacío son lo mismo: no hay nada exento, que es lo que le pasa a casi
|
|
18
|
+
// todo proyecto. Reclamarlo obligaría a los que no adoptaron nada a declarar una lista vacía.
|
|
19
|
+
function read(dir) {
|
|
20
|
+
return P.read(path.join(dir, BASELINE)).split('\n')
|
|
21
|
+
.map((line) => line.replace(/#.*$/, '').trim())
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Las tres cosas que hay que ver de una lista de perdones, y las tres son advertencias: la exención es
|
|
26
|
+
// legítima —el proyecto la declaró al adoptar— y lo único que no puede es dejar de verse.
|
|
27
|
+
//
|
|
28
|
+
// Que un slug ya cumpla el contrato no se detecta leyendo la lista: hay que volver a juzgar la entrada,
|
|
29
|
+
// que es para lo que existe `doneEntryErrors`. Sin este aviso la lista sólo envejece — nadie se entera
|
|
30
|
+
// de que doce de sus noventa perdones dejaron de hacer falta, y la exención sobrevive a su razón.
|
|
31
|
+
function report({ done, epics = [], adopted = [] }) {
|
|
32
|
+
const slugs = [...new Set(adopted)]
|
|
33
|
+
const warnings = []
|
|
34
|
+
for (const slug of slugs) {
|
|
35
|
+
const entry = done.entries.find((candidate) => candidate.slug === slug)
|
|
36
|
+
if (!entry) {
|
|
37
|
+
warnings.push(`${BASELINE}: ${slug} no está en DONE.md; sacalo de la lista`)
|
|
38
|
+
} else if (!PC.doneEntryErrors(entry, epics).length) {
|
|
39
|
+
warnings.push(`${BASELINE}: ${slug} ya cumple el contrato; sacalo de la lista`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (slugs.length) {
|
|
43
|
+
warnings.push(`${BASELINE}: ${slugs.length} entrada(s) exenta(s) del contrato de evidencia por adopción`)
|
|
44
|
+
}
|
|
45
|
+
return warnings
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { BASELINE, read, report }
|
|
@@ -64,6 +64,25 @@ function validateDoneEntry(entry, cited = []) {
|
|
|
64
64
|
return errors
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Los cinco juicios que recibe una entrada de DONE, juntos. Estaban repartidos entre el bucle que las
|
|
68
|
+
// recorre y `validateDoneEntry`, y ese reparto no se nota hasta que algo tiene que preguntar «¿a esta
|
|
69
|
+
// entrada le falta *algo*?»: la primera versión de la exención por adopción se escribió en una de las
|
|
70
|
+
// dos mitades y las otras cuatro comprobaciones seguían fallando.
|
|
71
|
+
//
|
|
72
|
+
// Los criterios que la historia declaró cubrir los cita el roadmap y no la entrada, así que el cruce
|
|
73
|
+
// sólo existe si la entrada dice de qué épica viene.
|
|
74
|
+
function doneEntryErrors(entry, epics = []) {
|
|
75
|
+
const at = `${entry.source} ${entry.slug}`
|
|
76
|
+
const errors = []
|
|
77
|
+
if (!entry.acceptance) errors.push(`${at}: falta acept:`)
|
|
78
|
+
if (!entry.done) errors.push(`${at}: falta done:`)
|
|
79
|
+
if (!entry.qa) errors.push(`${at}: falta qa:`)
|
|
80
|
+
if (!entry.commit) errors.push(`${at}: falta commit:`)
|
|
81
|
+
const story = epics.find((epic) => epic.num === entry.epic)?.stories
|
|
82
|
+
.find((candidate) => candidate.slug === entry.slug)
|
|
83
|
+
return [...errors, ...validateDoneEntry(entry, story ? story.criteria : [])]
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
function duplicates(values) {
|
|
68
87
|
return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]
|
|
69
88
|
}
|
|
@@ -299,7 +318,9 @@ function validateAdr(dir) {
|
|
|
299
318
|
// humanas. Vive acá y no en el CLI porque es de la misma clase que sus vecinas —`validateEpic`,
|
|
300
319
|
// `validateDoneEntry`, `validateRules`— y estaba creciendo del otro lado sólo porque ahí era más
|
|
301
320
|
// rápido escribirla. No lee nada: recibe el estado, así que se prueba sin tocar disco.
|
|
302
|
-
function validateState({
|
|
321
|
+
function validateState({
|
|
322
|
+
epics, milestones, done, wip, roles = new Set(), humanActions = [], adopted = new Set(),
|
|
323
|
+
}) {
|
|
303
324
|
const errors = []
|
|
304
325
|
const epicNums = new Set()
|
|
305
326
|
const storySlugs = new Set()
|
|
@@ -396,15 +417,11 @@ function validateState({ epics, milestones, done, wip, roles = new Set(), humanA
|
|
|
396
417
|
}
|
|
397
418
|
|
|
398
419
|
for (const entry of done.entries) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
// el cruce sólo existe si la entrada dice de qué épica viene.
|
|
405
|
-
const story = epics.find((epic) => epic.num === entry.epic)?.stories
|
|
406
|
-
.find((candidate) => candidate.slug === entry.slug)
|
|
407
|
-
errors.push(...validateDoneEntry(entry, story ? story.criteria : []))
|
|
420
|
+
// La entrada exenta por adopción se saltea entera. Es por entrada y no por campo ausente: una
|
|
421
|
+
// historia escrita bajo otro contrato puede traer un `commit:` con formato ajeno, y perdonar sólo
|
|
422
|
+
// «falta X» la dejaría fallando por lo que sí escribió.
|
|
423
|
+
if (adopted.has(entry.slug)) continue
|
|
424
|
+
errors.push(...doneEntryErrors(entry, epics))
|
|
408
425
|
}
|
|
409
426
|
return errors
|
|
410
427
|
}
|
|
@@ -424,36 +441,9 @@ function validateState({ epics, milestones, done, wip, roles = new Set(), humanA
|
|
|
424
441
|
// cuando está funcionando— y queda silenciado para todos sin que nadie lo decida caso por caso. R7 sí
|
|
425
442
|
// deja los suyos al proyecto, pero con su razón: dependen del lenguaje y de la superficie. Cinco
|
|
426
443
|
// condiciones que un plan tiene que satisfacer a la vez no dependen de ninguna de las dos.
|
|
427
|
-
const R17 = { taskCriteria: 5, epicCriteria: 7, milestoneTasks: 9 }
|
|
428
|
-
|
|
429
|
-
// Se cuenta lo que está estructurado: criterios de la épica, criterios que hereda una tarea, tareas del
|
|
430
|
-
// hito. Quedan afuera las dos cosas que no son un conteo: la aceptación escrita en prosa —cuántas
|
|
431
|
-
// condiciones tiene una frase es una lectura, y un número inventado ahí sería peor que ninguno— y la
|
|
432
|
-
// segunda barra de R17, las cuatro horas de esfuerzo, que no está en el artefacto. Las dos las mira el
|
|
433
|
-
// review, que para eso está R3, y la de esfuerzo es la que R17 dice que encuentra lo que ésta deja pasar.
|
|
434
|
-
function oversizedUnits({ epics = [], milestones = [] }) {
|
|
435
|
-
const errors = []
|
|
436
|
-
const undecided = (what, count, limit) =>
|
|
437
|
-
`${what}: ${count} (umbral ${limit} de R17). Revisá si son dos resultados con vidas distintas y `
|
|
438
|
-
+ 'partilo; si es uno solo, partirlo lo empeora — dejalo entero agregando "(sin partir: <razón>)"'
|
|
439
|
-
const judge = (unit, what, count, limit) => {
|
|
440
|
-
if (count > limit && !unit.noSplit) errors.push(undecided(what, count, limit))
|
|
441
|
-
}
|
|
442
|
-
for (const epic of epics) {
|
|
443
|
-
judge(epic, `roadmap/${epic.file}: criterios`, epic.criteria.length, R17.epicCriteria)
|
|
444
|
-
}
|
|
445
|
-
for (const milestone of milestones) {
|
|
446
|
-
judge(milestone, `hito ${milestone.slug}: tareas`, milestone.tasks.length, R17.milestoneTasks)
|
|
447
|
-
for (const task of milestone.tasks) {
|
|
448
|
-
judge(task, `BACKLOG ${task.slug}: criterios`, task.criteria.length, R17.taskCriteria)
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
return errors
|
|
452
|
-
}
|
|
453
|
-
|
|
454
444
|
module.exports = {
|
|
455
445
|
validateState,
|
|
456
|
-
|
|
446
|
+
doneEntryErrors,
|
|
457
447
|
validateAdr,
|
|
458
448
|
validateRules,
|
|
459
449
|
retiredByOverride,
|
|
@@ -138,6 +138,16 @@ function readEpics(dir) {
|
|
|
138
138
|
criteria,
|
|
139
139
|
stories,
|
|
140
140
|
hasContext: /^##\s+Contexto relevante/im.test(text),
|
|
141
|
+
// El molde describe esta sección como «lo que el ejecutor lee antes de decidir el cómo», y `check`
|
|
142
|
+
// da error si falta. Hasta acá se comprobaba que estuviera y se tiraba el texto en el mismo
|
|
143
|
+
// renglón, así que quien tenía que leerla nunca la recibía: `context` la resuelve por él, que
|
|
144
|
+
// además tiene prohibido ir a buscarla.
|
|
145
|
+
//
|
|
146
|
+
// Son dos campos y no uno porque contestan distinto: `hasContext` dice si el encabezado está
|
|
147
|
+
// —que es lo que `check` exige hoy— y `context` trae el cuerpo, que puede estar vacío debajo de
|
|
148
|
+
// un encabezado presente. Unificarlos convertiría una sección vacía en un error nuevo, que es
|
|
149
|
+
// otra decisión y no ésta.
|
|
150
|
+
context: section(text, /Contexto relevante/i).split('\n').slice(1).join('\n').trim(),
|
|
141
151
|
noSplit: noSplitReason(text),
|
|
142
152
|
// Las líneas que todavía no decidieron nada. Se guardan enteras y no como un booleano porque el
|
|
143
153
|
// error tiene que decir cuál es: «tiene un marcador» manda a releer la épica entera.
|
|
@@ -167,6 +177,22 @@ function readCast(rest) {
|
|
|
167
177
|
// lo está—. Cierra el `_` que markdown cerraría: el que no está entre caracteres de palabra.
|
|
168
178
|
const ACCEPTANCE = /_Aceptaci[oó]n:\s*(.*?\S)_(?![A-Za-z0-9])/i
|
|
169
179
|
|
|
180
|
+
// Cuántas condiciones tiene una aceptación escrita en prosa. Estuvo mucho tiempo sin contarse con una
|
|
181
|
+
// razón buena —contar condiciones en una frase es una lectura, y un número inventado es peor que
|
|
182
|
+
// ninguno—, y lo que la resuelve es no leer: se cuenta lo que el autor marcó. Los `(N)` cuando hay más
|
|
183
|
+
// de uno, y si no los hay, los tramos que él mismo separó con `;`.
|
|
184
|
+
//
|
|
185
|
+
// Sub-cuenta a propósito. Una frase larga con comas vale 1, y un solo `(1)` suelto también: un umbral
|
|
186
|
+
// que salta cuando no debe convierte la escapatoria de R17 en trámite, y ahí la razón se escribe para
|
|
187
|
+
// callar el mensaje en vez de para que alguien la lea. Un falso negativo deja las cosas como estaban.
|
|
188
|
+
function acceptanceConditions(value) {
|
|
189
|
+
const text = String(value || '').trim()
|
|
190
|
+
if (!text) return 0
|
|
191
|
+
const marcadas = (text.match(/\(\d+\)/g) || []).length
|
|
192
|
+
if (marcadas >= 2) return marcadas
|
|
193
|
+
return text.split(';').map((one) => one.trim()).filter(Boolean).length
|
|
194
|
+
}
|
|
195
|
+
|
|
170
196
|
function readBacklog(dir) {
|
|
171
197
|
const text = withoutComments(read(path.join(dir, 'BACKLOG.md')))
|
|
172
198
|
const milestones = []
|
|
@@ -185,11 +211,13 @@ function readBacklog(dir) {
|
|
|
185
211
|
const task = line.match(TASK_LINE)
|
|
186
212
|
if (!task || !current) continue
|
|
187
213
|
const rest = task[3]
|
|
214
|
+
const acceptance = ((rest.match(ACCEPTANCE) || [])[1] || '').trim()
|
|
188
215
|
current.tasks.push({
|
|
189
216
|
slug: task[1].trim(), tier: task[2] || '', cast: readCast(rest),
|
|
190
217
|
epic: ((rest.match(/\(epic:\s*(\d{3})\)/) || [])[1] || ''),
|
|
191
218
|
service: ((rest.match(/\(service:\s*([^)]+)\)/) || [])[1] || '').trim(),
|
|
192
|
-
acceptance
|
|
219
|
+
acceptance,
|
|
220
|
+
conditions: acceptanceConditions(acceptance),
|
|
193
221
|
criteria: criteriaRefs(rest),
|
|
194
222
|
noSplit: noSplitReason(rest),
|
|
195
223
|
})
|
|
@@ -207,6 +235,25 @@ function doneFiles(dir) {
|
|
|
207
235
|
return files
|
|
208
236
|
}
|
|
209
237
|
|
|
238
|
+
const DONE_FIELDS = 'acept|done|qa|tests|decisions|commit'
|
|
239
|
+
|
|
240
|
+
// Un campo vale hasta el próximo campo, una línea en blanco o el fin de la entrada. Mismo corte que ya
|
|
241
|
+
// se arregló para los criterios y las historias, con el mismo síntoma: el valor es prosa y se envuelve a
|
|
242
|
+
// 120 columnas, así que leer sólo la primera línea dejaba afuera lo que el autor escribió y `check`
|
|
243
|
+
// reportaba una ausencia que no existía —«decisions debe citar» sobre un campo cuya cita cerraba abajo—.
|
|
244
|
+
//
|
|
245
|
+
// El corte por línea en blanco no es simetría: el último campo es el único que no tiene otro campo
|
|
246
|
+
// detrás, y sin él se traga lo que venga después dentro de la entrada. En silencio, además, porque
|
|
247
|
+
// `validCommitTrace` sigue aprobando un `commit` contaminado mientras el prefijo sea válido.
|
|
248
|
+
//
|
|
249
|
+
// La sangría es `[^\S\n]` y no `\s`: `\s` incluye el salto, así que el match puede empezar en la línea
|
|
250
|
+
// anterior y arrastrar una línea en blanco adentro del valor.
|
|
251
|
+
function doneField(body, name) {
|
|
252
|
+
const pattern = new RegExp(`^[^\\S\\n]+${name}:[^\\S\\n]*([\\s\\S]*?)`
|
|
253
|
+
+ `(?=\\n[^\\S\\n]+(?:${DONE_FIELDS}):|\\n[^\\S\\n]*\\n|(?![\\s\\S]))`, 'mi')
|
|
254
|
+
return ((body.match(pattern) || [])[1] || '').replace(/\s+/g, ' ').trim()
|
|
255
|
+
}
|
|
256
|
+
|
|
210
257
|
function readDone(dir) {
|
|
211
258
|
const entries = []
|
|
212
259
|
for (const file of doneFiles(dir)) {
|
|
@@ -215,7 +262,7 @@ function readDone(dir) {
|
|
|
215
262
|
const matches = [...text.matchAll(donePattern)]
|
|
216
263
|
for (const match of matches) {
|
|
217
264
|
const body = match[3]
|
|
218
|
-
const field = (name) => (
|
|
265
|
+
const field = (name) => doneField(body, name)
|
|
219
266
|
entries.push({
|
|
220
267
|
slug: match[1].trim(),
|
|
221
268
|
epic: ((match[2].match(/\(epic:\s*(\d{3})\)/) || [])[1] || ''),
|
|
@@ -291,5 +338,6 @@ module.exports = {
|
|
|
291
338
|
EPIC_STATES, HUMAN_ACTION_STATES, LANES, MILESTONE_HEADING, STOP_REASONS,
|
|
292
339
|
TASK_LINE, TASK_LINE_ANY_LANE,
|
|
293
340
|
read, section, withoutComments, frontmatter, readEpics, readBacklog, readDone, readWip,
|
|
341
|
+
acceptanceConditions,
|
|
294
342
|
readInbox, readHumanActions,
|
|
295
343
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Las dos barras de R17 sobre lo que ya está escrito: cuántas condiciones acumula una unidad y cuántas
|
|
4
|
+
// unidades acumula la de arriba. Vive aparte de `contracts` porque no juzga si algo está bien escrito
|
|
5
|
+
// sino si es demasiado, y su reloj es el de la regla: los números y qué se cuenta cambian cuando cambia
|
|
6
|
+
// R17, no cuando cambia un contrato de planning.
|
|
7
|
+
//
|
|
8
|
+
// La otra barra de R17 —las cuatro horas de esfuerzo— no está acá ni puede estar: no vive en el
|
|
9
|
+
// artefacto. La mira el review, y es la que la regla dice que encuentra lo que ésta deja pasar.
|
|
10
|
+
|
|
11
|
+
const R17 = { taskCriteria: 5, epicCriteria: 7, milestoneTasks: 9 }
|
|
12
|
+
|
|
13
|
+
// Se cuenta lo que está estructurado —criterios de la épica, criterios heredados, tareas del hito— y
|
|
14
|
+
// también la aceptación propia de una tarea, que se cuenta como el autor la marcó y no leyéndola;
|
|
15
|
+
// `acceptanceConditions` explica hasta dónde llega esa cuenta.
|
|
16
|
+
//
|
|
17
|
+
// Estuvo afuera con una razón escrita: un número inventado sobre una frase es peor que ninguno, y lo que
|
|
18
|
+
// esta barra no vea lo ve el review de R3 más la segunda barra de R17. Lo que no previó fue el
|
|
19
|
+
// comportamiento. En un proyecto real seis de cuarenta y ocho tareas cruzaban el umbral en prosa y
|
|
20
|
+
// ninguna se vio, incluida la primera de la cola; y para la forma que el molde muestra primero, la
|
|
21
|
+
// escapatoria que R17 describe no se pedía nunca, porque el umbral no se cruzaba. La regla nombra esa
|
|
22
|
+
// acumulación —condiciones que se suman de a tandas en cada rechazo de plan— y ocurre justamente ahí.
|
|
23
|
+
function oversizedUnits({ epics = [], milestones = [] }) {
|
|
24
|
+
const errors = []
|
|
25
|
+
const undecided = (what, count, limit) =>
|
|
26
|
+
`${what}: ${count} (umbral ${limit} de R17). Revisá si son dos resultados con vidas distintas y `
|
|
27
|
+
+ 'partilo; si es uno solo, partirlo lo empeora — dejalo entero agregando "(sin partir: <razón>)"'
|
|
28
|
+
const judge = (unit, what, count, limit) => {
|
|
29
|
+
if (count > limit && !unit.noSplit) errors.push(undecided(what, count, limit))
|
|
30
|
+
}
|
|
31
|
+
for (const epic of epics) {
|
|
32
|
+
judge(epic, `roadmap/${epic.file}: criterios`, epic.criteria.length, R17.epicCriteria)
|
|
33
|
+
}
|
|
34
|
+
for (const milestone of milestones) {
|
|
35
|
+
judge(milestone, `hito ${milestone.slug}: tareas`, milestone.tasks.length, R17.milestoneTasks)
|
|
36
|
+
for (const task of milestone.tasks) {
|
|
37
|
+
// Se juzga la mayor de las dos y se nombra cuál: una tarea escribe su aceptación heredada o
|
|
38
|
+
// propia, casi nunca las dos, y sumarlas contaría dos veces a la que repite en prosa lo que ya
|
|
39
|
+
// citó. Nombrarla importa porque el mensaje llega solo: «criterios: 8» sobre una tarea sin un
|
|
40
|
+
// `(→ CN)` a la vista manda a buscar ocho referencias que no existen.
|
|
41
|
+
const propias = task.conditions || 0
|
|
42
|
+
const heredados = task.criteria.length
|
|
43
|
+
const que = heredados >= propias ? 'criterios' : 'condiciones de aceptación'
|
|
44
|
+
judge(task, `BACKLOG ${task.slug}: ${que}`, Math.max(heredados, propias), R17.taskCriteria)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return errors
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { oversizedUnits }
|
|
@@ -54,6 +54,14 @@
|
|
|
54
54
|
"additionalProperties": false
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
|
+
"writableOutsideRoots": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"description": "Rutas que el proyecto declara escribibles sin ser raíces de código —la memoria del runner, un scratchpad, un directorio de salida—. Sólo levantan el límite de workspace-boundary: no entran a scan ni al inventario de credenciales, que recorren workspaceRoots. `~` se expande a la casa del usuario y el resto se resuelve contra la raíz de ops. `check` las muestra resueltas en cada corrida, porque una exención que no se ve es un límite que ya no existe.",
|
|
60
|
+
"items": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"minLength": 1
|
|
63
|
+
}
|
|
64
|
+
},
|
|
57
65
|
"runner": {
|
|
58
66
|
"type": "object",
|
|
59
67
|
"required": [
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -122,10 +122,16 @@ Registra la acción exacta en `planning/HUMAN_ACTIONS.md` y, si bloquea todo, cr
|
|
|
122
122
|
`planning/AWAITING_REVIEW.md`.
|
|
123
123
|
|
|
124
124
|
Nunca amplía el alcance, promueve sus propias ideas, reescribe el proceso durante una tarea, usa
|
|
125
|
-
`git add .`/`git add -A`,
|
|
125
|
+
`git add .`/`git add -A`, reescribe historia con `--force` o `--amend`, ni afirma éxito sin evidencia
|
|
126
|
+
real. Tampoco publica: sin autorización no hay `push`.
|
|
127
|
+
|
|
128
|
+
Publicar es lo único de todo eso que este proyecto puede habilitar, y `runner.allowPush` en
|
|
129
|
+
`ops.config.json` es la autorización que R10 pide. Reescribir historia publicada no entra en el trato:
|
|
130
|
+
un `push --force` se frena con la llave prendida o apagada.
|
|
126
131
|
|
|
127
132
|
Eso rige sin que nadie escriba nada. Lo que este proyecto amplíe o restrinja va en
|
|
128
|
-
`organization/workspace.md`, con su razón;
|
|
133
|
+
`organization/workspace.md`, con su razón; ninguna de esas prohibiciones se amplía ahí, y la
|
|
134
|
+
publicación tampoco se decide ahí: la decide `allowPush`.
|
|
129
135
|
|
|
130
136
|
## Definición de terminado
|
|
131
137
|
|
|
@@ -51,6 +51,19 @@ nada falla.
|
|
|
51
51
|
|
|
52
52
|
Un comentario que cuesta escribir suele estar señalando el código, no la falta de palabras.
|
|
53
53
|
|
|
54
|
+
Y como en R14 y R15, no se detecta releyendo: quien lo escribió ya sabe por qué, y la copia se lee bien
|
|
55
|
+
precisamente porque lo que dice es cierto. Antes de entregar se recorren los comentarios que el cambio
|
|
56
|
+
agrega, uno por uno, y de cada uno se contesta si alguien lo preguntaría, si su razón ya está escrita en
|
|
57
|
+
otro lado y si está en el destino que le toca. Es mecánico y barato, y encuentra lo que releer no
|
|
58
|
+
encuentra: el que sobra por repetir lo que el nombre de al lado ya dice, y la razón que quedó en dos
|
|
59
|
+
lugares sin que ninguno de los dos se vea mal solo.
|
|
60
|
+
|
|
61
|
+
Una puerta que mida esto ayuda y no reemplaza a la pasada. Comparar textos encuentra la copia literal y
|
|
62
|
+
deja pasar las dos formas que más aparecen: la razón repetida apenas por debajo del umbral, y el
|
|
63
|
+
comentario que no repite a ningún otro porque repite el nombre que tiene al lado. Bajar el umbral hasta
|
|
64
|
+
que las agarre empieza a marcar lo que está bien, así que el número se elige para no molestar y la pasada
|
|
65
|
+
se hace igual.
|
|
66
|
+
|
|
54
67
|
## R18 — Un doble se justifica y los datos salen de una fábrica
|
|
55
68
|
|
|
56
69
|
Todo doble de prueba —mock, stub, fake, spy— declara en una línea por qué existe, y se busca en el lugar
|
|
@@ -34,3 +34,13 @@ producción, lo que prueba tampoco: queda verde para siempre sobre algo que nadi
|
|
|
34
34
|
## R10 — Publicación humana por defecto
|
|
35
35
|
|
|
36
36
|
Push, PR, merge, tags, deploy y rollback requieren la autorización configurada para el proyecto.
|
|
37
|
+
|
|
38
|
+
De esos seis, el motor comprueba uno: el push, contra `runner.allowPush`. Reescribir historia publicada
|
|
39
|
+
no entra en esa autorización y se frena siempre, igual que `--amend`. Los otros cinco no tienen una
|
|
40
|
+
forma reconocible en un comando —un deploy es `kubectl`, `terraform`, un script o un botón— y los
|
|
41
|
+
sostiene esta regla y el review, no un guard.
|
|
42
|
+
|
|
43
|
+
Decirlo es parte de la regla y no una nota al pie. Una norma que se presenta como comprobada donde no
|
|
44
|
+
lo está enseña a no creerle al resto: quien descubre que puede mergear sin que nada lo frene concluye
|
|
45
|
+
que la línea de arriba es decorativa, y esa conclusión se lleva puesto también lo que sí se comprueba.
|
|
46
|
+
Que el límite lo sostenga una persona no lo hace más blando; lo hace visible.
|
|
@@ -10,7 +10,9 @@ sandbox. Sin aprobación humana explícita:
|
|
|
10
10
|
- no editar secretos, credenciales, DNS, permisos o cuentas;
|
|
11
11
|
- no borrar datos ni ejecutar migraciones irreversibles.
|
|
12
12
|
|
|
13
|
-
Las excepciones se documentan en
|
|
13
|
+
Las excepciones se documentan en la sección «Integraciones y ambientes» de
|
|
14
|
+
`organization/workspace.md`, nombrando el entorno concreto: ese archivo es del proyecto y
|
|
15
|
+
`upgrade` no lo toca.
|
|
14
16
|
|
|
15
17
|
## R13 — Negarse no es entregar
|
|
16
18
|
|