@ingeniomaps/cauce 0.28.0 → 0.29.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 CHANGED
@@ -14,6 +14,33 @@ 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.29.0] - 2026-08-18
18
+
19
+ ### Añadido
20
+
21
+ - **`ops scan`: qué hay en el workspace, resuelto por código.** Lista los subproyectos con manifiesto
22
+ propio, su runtime y los comandos de test, lint y build que cada uno declara, diciendo de qué archivo
23
+ salió cada comando. No corre ninguno y no inventa ninguno: un comando que nadie declaró se lee igual
24
+ que uno real, y el primer Verify de una tarea es donde eso se descubre. Saltea `node_modules` y todo
25
+ directorio oculto, que es lo que lo mantiene en milisegundos. Una instancia sidecar escanea su carpeta
26
+ madre, donde vive el código; cualquier otro modo, donde está parada.
27
+
28
+ ### Cambiado
29
+
30
+ - **`/onboard` cuesta lo que encuentra.** Empezaba pidiéndole a un agente que explorara el repositorio y
31
+ terminaba corriendo la suite de tests de cada servicio: una corrida sobre una carpeta vacía gastó doce
32
+ minutos sin poder producir nada. Ahora arranca con `ops scan` —una llamada, milisegundos— y, si el
33
+ workspace no tiene código y nadie aportó contexto, termina ahí diciendo qué le falta, en vez de escribir
34
+ una empresa inventada.
35
+
36
+ El recorrido ya no ejecuta nada del proyecto. El mapa real anota cada comando **tal como está
37
+ declarado**, con su archivo de origen, y verificarlo corriéndolo pasa a ser una historia de la épica
38
+ 001, donde tiene dueño y tiempo asignado. Lo que escribe y lo que deja a una persona no cambió:
39
+ borradores marcados como supuestos, credenciales y MCP en `HUMAN_ACTIONS.md`, épica sin promover.
40
+
41
+ Si ya tenés una instancia, el recorrido actualizado llega con `automation install`, no con `upgrade`:
42
+ los workflows viven en el runner.
43
+
17
44
  ## [0.28.0] - 2026-08-18
18
45
 
19
46
  ### Añadido
package/README.md CHANGED
@@ -101,10 +101,11 @@ vacío. Llenarlo exige leer el repositorio y decidir qué es cada cosa, que es l
101
101
  no puede hacer, así que ese recorrido vive en el runner:
102
102
 
103
103
  ```text
104
- /onboard inventaría los servicios, corre sus comandos de test, lint y build, y escribe
105
- organization/, el «Mapa real» de AGENTS.md y las raíces de ops.config.json.
106
- Lo deducido queda marcado como supuesto; credenciales, MCP y el permiso de push
107
- van a HUMAN_ACTIONS.md. Cierra con la épica 001, sin promoverla.
104
+ /onboard parte del inventario de `ops scan` y escribe organization/, el «Mapa real» de
105
+ AGENTS.md —cada comando como está declarado y de qué archivo salió— y las raíces
106
+ de ops.config.json. Lo deducido queda marcado como supuesto; credenciales, MCP y
107
+ el permiso de push van a HUMAN_ACTIONS.md. Cierra con la épica 001, sin promoverla.
108
+ No corre nada del proyecto: verificar los comandos es una historia de esa épica.
108
109
  /team evalúa si una intención posterior es viable y propone su épica.
109
110
  /autobuild ejecuta una tarea ya promovida, fase por fase.
110
111
  ```
@@ -141,6 +142,7 @@ Lee [template/planning/PROTOCOL.md](template/planning/PROTOCOL.md) para el contr
141
142
  | Comando | Función |
142
143
  |---|---|
143
144
  | `ops init [destino]` | Materializa una instancia y la deja usable; sin destino, en `ops/` y modo sidecar. |
145
+ | `ops scan [workspace]` | Inventaría servicios y comandos declarados, sin correr ninguno. |
144
146
  | `ops check <planning>` | Valida contratos, unicidad, trazabilidad y estados. |
145
147
  | `ops tree <planning>` | Muestra roadmap, backlog, WIP, inbox y done sin mutar nada. |
146
148
  | `ops context <planning>` | Emite el contexto mínimo de la tarea vigente para un runner. |
@@ -8,11 +8,11 @@ este proyecto. Antes de empezar comprobá que siga vacía —`{{OPS_DIR}}organiz
8
8
  «Por completar» y `{{OPS_DIR}}planning/roadmap/` sin épicas—: reescribir un contexto que alguien ya
9
9
  corrigió no deja rastro de lo que se perdió.
10
10
 
11
- Inventariá los subproyectos con manifiesto propio, sin entrar en la raíz ops ni en `node_modules`. De cada
12
- uno tomá su ruta, para qué sirve y los comandos de test, lint y build que él mismo declara. **Corré esos
13
- comandos**: un mapa copiado del README envejece sin avisar y el primer Verify de una tarea real descubre
14
- que el comando no existe. Nunca corras migraciones, deploys ni publicaciones, aunque un script se llame
15
- así.
11
+ El inventario no lo hagas a mano: `node {{OPS_DIR}}tools/ops.js scan --json` devuelve los subproyectos con
12
+ manifiesto propio, su runtime y los comandos que cada uno declara, con el archivo del que salieron.
13
+ Recorrer directorios es determinista y cuesta milisegundos; explorarlo vos cuesta minutos y encuentra lo
14
+ mismo. No corras ningún comando del proyecto: el mapa dice lo que está declarado y de dónde, y
15
+ verificarlo corriéndolo es una historia de la épica, con dueño y tiempo asignado.
16
16
 
17
17
  Con eso escribí `{{OPS_DIR}}organization/company.md` y `product.md`, la sección «Mapa real» de
18
18
  `{{OPS_DIR}}AGENTS.md` con el resultado que obtuviste por comando, y las raíces reales en
@@ -37,10 +37,10 @@ En una instancia recién creada nadie le explicó todavía al toolkit qué es es
37
37
  `{{OPS_DIR}}organization/` llega como molde y el roadmap está vacío. El primer recorrido lo llena, y una vez: reescribir un contexto
38
38
  que alguien ya corrigió no deja rastro de lo que se perdió.
39
39
 
40
- Inventariá los subproyectos con manifiesto propio y **corré** los comandos de test, lint y build que cada
41
- uno declara —un mapa copiado del README envejece sin avisar—. Con eso escribí `{{OPS_DIR}}organization/`,
42
- la sección «Mapa real» de `{{OPS_DIR}}AGENTS.md` con el resultado por comando, y las raíces reales en
43
- `workspaceRoots`. Lo deducido va marcado `(supuesto)`. Credenciales, MCP y el permiso de push van como
40
+ Empezá por `node {{OPS_DIR}}tools/ops.js scan --json`, que devuelve los subproyectos con manifiesto propio
41
+ y los comandos que cada uno declara: recorrer el árbol vos mismo cuesta minutos y encuentra lo mismo. Con
42
+ eso escribí `{{OPS_DIR}}organization/`, la sección «Mapa real» de `{{OPS_DIR}}AGENTS.md` con cada comando
43
+ tal como está declarado y de qué archivo salió —sin correrlo—, y las raíces reales en `workspaceRoots`. Lo deducido va marcado `(supuesto)`. Credenciales, MCP y el permiso de push van como
44
44
  filas en `{{OPS_DIR}}planning/HUMAN_ACTIONS.md`, sin proponer valores. Cerrá con `epic-001` en
45
45
  `{{OPS_DIR}}planning/roadmap/`: que una tarea pueda atravesar el ciclo entero, con criterios que salen de
46
46
  lo que falta. Nunca la promuevas.
@@ -30,10 +30,10 @@ En una instancia recién creada nadie le explicó todavía al toolkit qué es es
30
30
  `{{OPS_DIR}}organization/` llega como molde y el roadmap está vacío. El primer recorrido lo llena, y una vez: reescribir un contexto
31
31
  que alguien ya corrigió no deja rastro de lo que se perdió.
32
32
 
33
- Inventariá los subproyectos con manifiesto propio y **corré** los comandos de test, lint y build que cada
34
- uno declara —un mapa copiado del README envejece sin avisar—. Con eso escribí `{{OPS_DIR}}organization/`,
35
- la sección «Mapa real» de `{{OPS_DIR}}AGENTS.md` con el resultado por comando, y las raíces reales en
36
- `workspaceRoots`. Lo deducido va marcado `(supuesto)`. Credenciales, MCP y el permiso de push van como
33
+ Empezá por `node {{OPS_DIR}}tools/ops.js scan --json`, que devuelve los subproyectos con manifiesto propio
34
+ y los comandos que cada uno declara: recorrer el árbol vos mismo cuesta minutos y encuentra lo mismo. Con
35
+ eso escribí `{{OPS_DIR}}organization/`, la sección «Mapa real» de `{{OPS_DIR}}AGENTS.md` con cada comando
36
+ tal como está declarado y de qué archivo salió —sin correrlo—, y las raíces reales en `workspaceRoots`. Lo deducido va marcado `(supuesto)`. Credenciales, MCP y el permiso de push van como
37
37
  filas en `{{OPS_DIR}}planning/HUMAN_ACTIONS.md`, sin proponer valores. Cerrá con `epic-001` en
38
38
  `{{OPS_DIR}}planning/roadmap/`: que una tarea pueda atravesar el ciclo entero, con criterios que salen de
39
39
  lo que falta. Nunca la promuevas.
@@ -1,26 +1,30 @@
1
1
  // Arranque de una instancia recién creada: convierte un repositorio que nadie le explicó al toolkit en
2
2
  // contexto escrito —`organization/`, el mapa real de `AGENTS.md`, las raíces de código— y en la primera
3
- // épica. Es el paso que `init` no puede dar: instalar es determinista, y leer un repositorio para decidir
4
- // qué es el producto, qué carpeta es legacy y cuál es el comando que de verdad lo valida, no lo es.
3
+ // épica.
5
4
  //
6
- // Escribe borradores y no decide por nadie: cada dato deducido queda marcado como supuesto, las
7
- // credenciales y los sistemas externos van a HUMAN_ACTIONS —R12 se los prohíbe a un runner— y la épica
8
- // queda en roadmap/ sin promover, que sigue siendo la firma humana.
5
+ // El orden importa y se pagó caro: la primera versión le pedía a un agente que «inventariara el
6
+ // repositorio», y en una carpeta vacía eso gastó doce minutos para no encontrar nada. Recorrer el árbol
7
+ // es determinista y lo hace `ops scan` en milisegundos; el modelo entra después, y sólo si hay algo
8
+ // sobre lo que decidir. Cuando no lo hay, este recorrido termina en una llamada.
9
+ //
10
+ // Escribe borradores y no decide por nadie: lo deducido queda marcado como supuesto, las credenciales y
11
+ // los sistemas externos van a HUMAN_ACTIONS —R12 se los prohíbe a un runner— y la épica queda sin
12
+ // promover, que sigue siendo la firma humana.
9
13
  export const meta = {
10
14
  name: 'onboard',
11
- description: 'Escanea el repositorio y deja escrito el contexto de la empresa y la primera épica',
15
+ description: 'Inventaría el workspace y deja escrito el contexto de la empresa y la primera épica',
12
16
  whenToUse: 'Primera corrida después de "cauce init", cuando organization/ y el roadmap están vacíos.',
13
17
  phases: [
14
- { title: 'Scan', detail: 'Servicios, manifiestos y comandos declarados' },
15
- { title: 'Verify', detail: 'Los comandos se corren: el mapa no se copia del README' },
16
- { title: 'Draft', detail: 'organization/, mapa real, raíces y acciones humanas' },
18
+ { title: 'Scan', detail: 'Inventario determinista: ops scan, sin modelo recorriendo nada' },
19
+ { title: 'Draft', detail: 'organization/, mapa real y raíces de código' },
20
+ { title: 'Human', detail: 'Credenciales, MCP y permisos: lo que no le toca al runner' },
17
21
  { title: 'Epic', detail: 'La épica que deja al ciclo poder correr' },
18
22
  { title: 'Closing', detail: 'check y lo que queda esperando a una persona' },
19
23
  ],
20
24
  }
21
25
 
22
- // El prefijo lo completa `automation install`. Igual que en los demás workflows: el runtime no expone
23
- // `process`, así que la ruta de la raíz ops viaja escrita, relativa a donde se abre la herramienta.
26
+ // El prefijo lo completa `automation install`: el runtime no expone `process`, así que la ruta de la
27
+ // raíz ops viaja escrita, relativa a donde se abre la herramienta.
24
28
  const ROOT = '{{OPS_DIR}}'.replace(/\/+$/, '') || '.'
25
29
  const P = `${ROOT}/planning`
26
30
  const ORG = `${ROOT}/organization`
@@ -28,18 +32,17 @@ const HUMAN = `${P}/HUMAN_ACTIONS.md`
28
32
  const INBOX = `${P}/INBOX.md`
29
33
  const ROADMAP = `${P}/roadmap`
30
34
 
31
- // Lo que la persona ya sabe y no hace falta deducir: `/onboard vendemos ruteo a PYMEs de logística`.
32
- // Entra como contexto, no como verdad: lo que diga acá se escribe como hecho, y lo deducido no.
35
+ // Lo que la persona ya sabe y el repositorio no puede decir: `/onboard vendemos ruteo a PYMEs de
36
+ // logística`. Entra como hecho; lo que se deduce, no.
33
37
  const input = typeof args === 'string' ? { context: args } : (args || {})
34
38
  const CONTEXT = String(input.context || '').trim()
35
39
  const FORCE = Boolean(input.force)
36
40
 
37
- const BASE = `Nunca inventes clientes, métricas, ingresos, plazos ni responsables. Distinguí siempre lo ` +
38
- `que verificaste corriendo algo, lo que leíste en un archivo del repositorio y lo que estás ` +
39
- `suponiendo: lo tercero va marcado como "(supuesto)" en el texto que escribas. No leas archivos de ` +
40
- `credenciales —.env, *.pem, claves— ni copies su contenido a ningún lado; .env.example sí, y sólo los ` +
41
- `nombres de las variables. No escribas en ningún sistema externo, no conectes nada y no promuevas ` +
42
- `trabajo al BACKLOG.`
41
+ const BASE = `Nunca inventes clientes, métricas, ingresos, plazos ni responsables. Distinguí lo que leíste ` +
42
+ `en un archivo de lo que estás suponiendo: lo segundo va marcado "(supuesto)" en el texto que escribas. ` +
43
+ `No leas archivos de credenciales —.env, *.pem, claves— ni copies su contenido a ningún lado; ` +
44
+ `.env.example sí, y sólo los nombres de las variables. No corras comandos del proyecto: este recorrido ` +
45
+ `no ejecuta nada, sólo lee. No escribas en ningún sistema externo y no promuevas trabajo al BACKLOG.`
43
46
 
44
47
  function finish(result) {
45
48
  log(`Fin: ${JSON.stringify(result)}`)
@@ -51,50 +54,29 @@ const stop = (reason, detail = '') => {
51
54
  return finish({ stopped: true, reason, detail })
52
55
  }
53
56
 
54
- const STATE = {
55
- type: 'object', additionalProperties: false, required: ['fresh'],
57
+ const SCAN = {
58
+ type: 'object', additionalProperties: false, required: ['fresh', 'services'],
56
59
  properties: {
57
60
  fresh: { type: 'boolean' },
58
61
  reason: { type: 'string' },
59
- mode: { type: 'string' },
60
- workspaceRoots: { type: 'array', items: { type: 'string' } },
61
- },
62
- }
63
-
64
- const INVENTORY = {
65
- type: 'object', additionalProperties: false, required: ['services'],
66
- properties: {
67
62
  services: { type: 'array', items: { type: 'object', additionalProperties: false,
68
- required: ['path', 'runtime'], properties: {
69
- path: { type: 'string' }, runtime: { type: 'string' }, purpose: { type: 'string' },
70
- test: { type: 'string' }, lint: { type: 'string' }, build: { type: 'string' },
71
- source: { type: 'string' },
63
+ required: ['path'], properties: {
64
+ path: { type: 'string' },
65
+ runtimes: { type: 'array', items: { type: 'string' } },
66
+ // Comando declarado y de qué archivo salió. Verificar que además corra es una historia de la
67
+ // épica: correr la suite de cada servicio acá convertía el arranque en una espera larga.
68
+ commands: { type: 'array', items: { type: 'object', additionalProperties: false,
69
+ required: ['kind', 'command', 'source'], properties: {
70
+ kind: { type: 'string' }, command: { type: 'string' }, source: { type: 'string' },
71
+ } } },
72
72
  } } },
73
- legacy: { type: 'array', items: { type: 'string' } },
74
- ci: { type: 'array', items: { type: 'string' } },
75
73
  externals: { type: 'array', items: { type: 'string' } },
76
74
  secrets: { type: 'array', items: { type: 'string' } },
77
- productHints: { type: 'string' },
78
- },
79
- }
80
-
81
- const CHECKED = {
82
- type: 'object', additionalProperties: false, required: ['path', 'results'],
83
- properties: {
84
- path: { type: 'string' },
85
- results: { type: 'array', items: { type: 'object', additionalProperties: false,
86
- required: ['kind', 'command', 'status'], properties: {
87
- kind: { type: 'string', enum: ['test', 'lint', 'build'] },
88
- command: { type: 'string' },
89
- // `ausente` es un resultado, no un fallo: un servicio sin lint declarado no tiene nada roto.
90
- status: { type: 'string', enum: ['verificado', 'falla', 'ausente'] },
91
- detail: { type: 'string' },
92
- } } },
93
75
  },
94
76
  }
95
77
 
96
78
  const WRITTEN = {
97
- type: 'object', additionalProperties: false, required: ['files', 'assumptions'],
79
+ type: 'object', additionalProperties: false, required: ['files'],
98
80
  properties: {
99
81
  files: { type: 'array', items: { type: 'string' } },
100
82
  assumptions: { type: 'array', items: { type: 'string' } },
@@ -105,122 +87,97 @@ const WRITTEN = {
105
87
 
106
88
  phase('Scan')
107
89
 
108
- // Arrancar dos veces sobre la misma instancia reescribiría contexto que una persona ya corrigió, y el
109
- // borrador se lee igual que el original: nada delataría la pérdida. Se comprueba antes de leer nada más.
90
+ // Una sola llamada, y todo lo que hace es correr dos comandos y mirar dos archivos. Lo que sigue depende
91
+ // de lo que devuelva, así que gastar más antes de saberlo es gastar a ciegas.
110
92
  const state = await agent(
111
- `${BASE}\n\nFrom the workspace root, report whether this instance is still untouched. Read ` +
112
- `${ROOT}/ops.config.json for its mode and workspaceRoots, ${ORG}/company.md and ${ORG}/product.md, and ` +
113
- `list ${ROADMAP}. Set fresh=true only if the organization files still carry the mold's "Por completar" ` +
114
- `or "Por definir" placeholders and the roadmap holds nothing but its template and README. Otherwise ` +
115
- `set fresh=false and say in reason what is already written.`,
116
- { schema: STATE, label: 'estado-inicial' },
93
+ `${BASE}\n\nFrom ${ROOT}, run exactly these two commands and report what they printed. Explore nothing ` +
94
+ `else and open no other file than the two named below.\n` +
95
+ `1. "node tools/ops.js scan --json": the workspace inventory. Copy each service with its path, its ` +
96
+ `runtimes and its declared commands, keeping the source file each command came from. Add nothing that ` +
97
+ `the command did not print.\n` +
98
+ `2. "node tools/ops.js check planning".\n` +
99
+ `Then read ${ORG}/company.md and list ${ROADMAP}. Set fresh=true only if the organization file still ` +
100
+ `carries the mold's "Por completar" placeholders and the roadmap holds nothing but its template and ` +
101
+ `README; otherwise fresh=false and say in reason what is already written. If .env.example exists at ` +
102
+ `the workspace root, report the variable names in secrets —names only— and the external services they ` +
103
+ `point at in externals.`,
104
+ { schema: SCAN, label: 'inventario' },
117
105
  )
118
- if (!state) return stop('estado-desconocido', 'no se pudo leer el estado de la instancia')
106
+ if (!state) return stop('scan-unavailable', 'no se pudo leer el estado del workspace')
119
107
  if (!state.fresh && !FORCE) {
120
108
  return stop('ya-arrancado', `${state.reason || 'la instancia ya tiene contexto escrito'}. ` +
121
109
  `Pasá force:true si querés reescribir los borradores.`)
122
110
  }
123
111
 
124
- const inventory = await agent(
125
- `${BASE}\n\nInventariá el repositorio desde la raíz del workspace, sin entrar en ${ROOT}/ ni en ` +
126
- `node_modules, vendor, dist o build. Para cada subproyecto con manifiesto propio —package.json, ` +
127
- `go.mod, pyproject.toml, Cargo.toml, composer.json, pom.xml, Gemfile, Makefile, Dockerfile o ` +
128
- `docker-compose— reportá su ruta relativa, el runtime, para qué parece servir, y los comandos de test, ` +
129
- `lint y build que el propio proyecto declara, con source apuntando al archivo y la clave de donde los ` +
130
- `sacaste. Un comando que no está declarado se omite: no lo adivines.\n\n` +
131
- `Reportá además qué directorios parecen legacy o fuera de alcance, qué corre en CI, qué servicios ` +
132
- `externos aparecen nombrados en configuración o dependencias, y qué credenciales espera el proyecto ` +
133
- `—sólo los nombres de variable, leídos de .env.example o de la configuración de CI—. En productHints ` +
134
- `resumí lo que el repositorio deja ver sobre qué se construye.` +
135
- `${CONTEXT ? `\n\nLa persona ya aportó este contexto, que vale como hecho: ${CONTEXT}` : ''}`,
136
- { schema: INVENTORY, label: 'inventario' },
137
- )
138
- if (!inventory) return stop('inventario-vacio', 'el escaneo no devolvió resultado')
139
- const services = inventory.services || []
140
-
141
- // Un workspace sin código no es un error: alguien puede estar preparando la carpeta antes de clonar los
142
- // repos, y `init` en un directorio vacío es un arranque legítimo. Lo que no puede es terminar sin nada
143
- // escrito: lo que sí se pueda establecer se escribe igual, y traer el código pasa a ser la primera
144
- // historia en vez de un checkpoint que no deja nada.
145
- const VACIO = services.length ? '' : `\n\nNo hay ningún subproyecto con manifiesto propio en el ` +
146
- `workspace: el código todavía no está acá. Escribí igual lo que el contexto aportado permita, dejá el ` +
147
- `mapa real declarado como pendiente diciendo qué lo completa, y que la primera historia de la épica sea ` +
148
- `traer los repos y declararlos en workspaceRoots.`
149
- log(services.length
150
- ? `${services.length} servicio(s): ${services.map((service) => service.path).join(', ')}`
151
- : 'Sin servicios en el workspace: el arranque escribe lo que se pueda y deja el mapa pendiente.')
112
+ const services = state.services || []
113
+ const listado = services.map((service) => service.path).join(', ')
114
+ log(`${services.length} servicio(s) en el workspace${listado ? `: ${listado}` : ''}`)
152
115
 
153
- phase('Verify')
154
-
155
- // Un mapa copiado del README envejece sin avisar y el primer Verify de una tarea real descubre que el
156
- // comando no existe. Correrlos acá es barato y es lo que separa "documentado" de "verificado".
157
- const checks = (await parallel(services.map((service) => () => agent(
158
- `${BASE}\n\nFrom the workspace root, check the commands declared by the service at "${service.path}": ` +
159
- `test=${service.test || '(ninguno)'}, lint=${service.lint || '(ninguno)'}, ` +
160
- `build=${service.build || '(ninguno)'}. Run each one that exists, from that directory, and report what ` +
161
- `happened: "verificado" when it finished green, "falla" with the first meaningful error line when it ` +
162
- `did not, "ausente" when the service declares none. Never run migrations, deploys, publishes, or ` +
163
- `anything that writes outside this repository, even if a script has that name: report it as ausente ` +
164
- `and say why.`,
165
- { schema: CHECKED, label: `verify:${service.path}`, phase: 'Verify' },
166
- )))).filter(Boolean)
116
+ // Sin código y sin contexto no hay nada que escribir que no sea inventado, y esa es exactamente la
117
+ // corrida que no puede costar nada: se termina acá, diciendo qué falta y cómo darlo.
118
+ if (!services.length && !CONTEXT) {
119
+ return stop('sin-contexto', `no hay servicios en el workspace y no me diste contexto, así que no hay ` +
120
+ `nada que escribir sin inventarlo. Volvé a correrlo cuando estén los repos, o dame el contexto en ` +
121
+ `una línea: /onboard qué vende la empresa, a quién, y cuál es el objetivo del trimestre.`)
122
+ }
167
123
 
168
- const green = checks.flatMap((entry) => entry.results.filter((result) => result.status === 'verificado')).length
169
- const broken = checks.flatMap((entry) => entry.results.filter((result) => result.status === 'falla'))
170
- log(`${green} comando(s) verificados, ${broken.length} con fallo. Un fallo no detiene el arranque: se escribe.`)
124
+ const INVENTARIO = { services, externals: state.externals || [], secrets: state.secrets || [] }
125
+ const EVIDENCE = `Inventario del workspace:\n${JSON.stringify(INVENTARIO)}` +
126
+ `${CONTEXT ? `\n\nContexto aportado por la persona, que vale como hecho: ${CONTEXT}` : ''}` +
127
+ `${services.length ? '' : '\n\nNo hay ningún servicio en el workspace: el código todavía no está acá.'}`
171
128
 
172
129
  phase('Draft')
173
130
 
174
- const EVIDENCE = `Inventario:\n${JSON.stringify(inventory)}\n\n` +
175
- `Comandos comprobados:\n${JSON.stringify(checks)}${VACIO}` +
176
- `${CONTEXT ? `\n\nContexto aportado por la persona, que vale como hecho: ${CONTEXT}` : ''}`
177
-
178
131
  const drafted = await agent(
179
- `${BASE}\n\n${EVIDENCE}\n\nEscribí los borradores de contexto de esta instancia. Reemplazá el molde, no ` +
180
- `lo comentes:\n` +
181
- `1. ${ORG}/company.md y ${ORG}/product.md: lo que el repositorio y el contexto aportado permiten ` +
182
- `afirmar. Lo deducido va marcado "(supuesto)"; lo que nada sostiene queda como "Por definir" y su ` +
183
- `pregunta va a openQuestions. No inventes clientes, ingresos ni objetivos.\n` +
184
- `2. La sección "## Mapa real" de ${ROOT}/AGENTS.md: una entrada por servicio con su ruta, para qué ` +
185
- `sirve y sus comandos, cada uno con el resultado que obtuviste —verificado, falla o ausente—. Enlazá ` +
186
- `la fuente en vez de duplicar documentación técnica, y nombrá lo legacy y lo fuera de alcance.\n` +
187
- `3. ${ROOT}/ops.config.json: dejá en workspaceRoots las raíces de código reales. Es lo que un guard usa ` +
188
- `para bloquear una escritura fuera de lugar, así que una raíz de más lo apaga.\n` +
132
+ `${BASE}\n\n${EVIDENCE}\n\nEscribí los borradores de contexto de esta instancia, reemplazando el molde ` +
133
+ `en vez de comentarlo:\n` +
134
+ `1. ${ORG}/company.md y ${ORG}/product.md: lo que el contexto aportado y los nombres del repositorio ` +
135
+ `permiten afirmar. Lo que nada sostiene queda "Por definir" y su pregunta va a openQuestions.\n` +
136
+ `2. La sección "## Mapa real" de ${ROOT}/AGENTS.md: una entrada por servicio con su ruta, su runtime y ` +
137
+ `sus comandos **tal como los declara**, diciendo de qué archivo salió cada uno. No afirmes que ` +
138
+ `funcionan: nadie los corrió. Un servicio sin comandos declarados se escribe así, que es información.\n` +
139
+ `3. ${ROOT}/ops.config.json: dejá en workspaceRoots las raíces de código reales, que es lo que un guard ` +
140
+ `usa para bloquear una escritura fuera de lugar. Una raíz de más lo apaga.\n` +
141
+ `${services.length ? '' : 'Sin servicios, el mapa queda declarado como pendiente, diciendo qué lo ' +
142
+ 'completa.\n'}` +
189
143
  `Devolvé en files cada archivo que tocaste y en assumptions cada supuesto que dejaste marcado.`,
190
144
  { schema: WRITTEN, label: 'contexto' },
191
145
  )
192
146
  if (!drafted) return stop('draft-unavailable', 'los borradores no devolvieron resultado')
193
147
 
148
+ phase('Human')
149
+
194
150
  // Credenciales, MCP y permiso de push no son trabajo del runner: R12 se los prohíbe y R13 exige dejar
195
151
  // dicho quién los resuelve y con qué. La fila vale más que la negativa.
196
152
  const pending = await agent(
197
153
  `${BASE}\n\n${EVIDENCE}\n\nRegistrá en ${HUMAN} una fila por cada cosa que necesita a una persona, con ` +
198
- `la tarea, el estado pendiente, el origen "onboard" y la acción concreta que la desbloquea. Como mínimo, ` +
199
- `una por cada credencial que el proyecto espera —diciendo dónde se cargan en este proyecto y quién lo ` +
200
- `hace, sin proponer ningún valor—, una por cada servicio externo o MCP a conectar —con su alcance y ` +
201
- `contra qué entorno—, y una por la autoridad del runner: hoy ops.config.json declara ` +
202
- `runner.allowPush=false y cambiarlo es una decisión humana. Las preguntas que quedaron abiertas van a ` +
203
- `la sección Ideas de ${INBOX}, sin promover: ${JSON.stringify(drafted.openQuestions || [])}`,
154
+ `la tarea, el estado pendiente, el origen "onboard" y la acción concreta que la desbloquea. Como ` +
155
+ `mínimo: una por cada credencial que el proyecto espera —dónde se cargan y quién lo hace, sin proponer ` +
156
+ `ningún valor—, una por cada servicio externo o MCP a conectar —con su alcance y contra qué entorno— y ` +
157
+ `una por la autoridad del runner, que hoy declara runner.allowPush=false en ops.config.json. Las ` +
158
+ `preguntas abiertas van a la sección Ideas de ${INBOX}, sin promover: ` +
159
+ `${JSON.stringify(drafted.openQuestions || [])}`,
204
160
  { schema: WRITTEN, label: 'acciones-humanas' },
205
161
  )
206
162
 
207
163
  phase('Epic')
208
164
 
209
165
  const epic = await agent(
210
- `${BASE}\n\n${EVIDENCE}\n\nSupuestos que quedaron escritos: ${JSON.stringify(drafted.assumptions || [])}\n` +
211
- `Comandos que fallaron: ${JSON.stringify(broken)}\n\n` +
166
+ `${BASE}\n\n${EVIDENCE}\n\nSupuestos que quedaron escritos: ${JSON.stringify(drafted.assumptions || [])}\n\n` +
212
167
  `Escribí en ${ROADMAP} la épica epic-001-<slug>.md siguiendo el contrato de ${P}/PROTOCOL.md: ` +
213
168
  `frontmatter epic/title/status/service con status open, criterios **CN** observables, "## Contexto ` +
214
- `relevante" con rutas verificadas, e historias con (→ CN) y (service: ruta), cada una de menos de ` +
215
- `cuatro horas.\n\n` +
169
+ `relevante" con rutas reales e historias con (→ CN) y (service: ruta), cada una de menos de cuatro ` +
170
+ `horas.\n\n` +
216
171
  `Su resultado es que una tarea pueda atravesar el ciclo entero sin que nadie tenga que volver a ` +
217
- `explicar este proyecto. Los criterios salen de lo que hoy falta y son verificables: que ` +
218
- `organization/ no tenga supuestos sin confirmar, que cada servicio del mapa tenga su comando ` +
219
- `corriendo en verde, que las raíces declaradas hagan que el guard de límites bloquee una escritura ` +
220
- `afuera y deje pasar una adentro, y que una tarea piloto real llegue a DONE con evidencia. Si algún ` +
221
- `comando falló, arreglarlo es una historia, no un criterio aparte. En "## Riesgos y decisiones ` +
222
- `humanas" citá las filas que dejaste en HUMAN_ACTIONS. No toques BACKLOG.md.`,
223
- { schema: { type: 'object', additionalProperties: false, required: ['file', 'criteria', 'stories'],
172
+ `explicar este proyecto. Los criterios salen de lo que hoy falta y son verificables: que organization/ ` +
173
+ `no tenga supuestos sin confirmar, que cada comando del mapa esté verificado corriéndolo y anotado con ` +
174
+ `su resultado, que las raíces declaradas hagan que el guard de límites bloquee una escritura afuera y ` +
175
+ `deje pasar una adentro, y que una tarea piloto real llegue a DONE con evidencia. ` +
176
+ `${services.length
177
+ ? 'Verificar los comandos es una historia: nadie los corrió todavía.'
178
+ : 'La primera historia es traer los repos y declararlos en workspaceRoots.'} ` +
179
+ `En "## Riesgos y decisiones humanas" citá las filas que quedaron en HUMAN_ACTIONS. No toques BACKLOG.md.`,
180
+ { schema: { type: 'object', additionalProperties: false, required: ['file'],
224
181
  properties: {
225
182
  file: { type: 'string' }, title: { type: 'string' },
226
183
  criteria: { type: 'array', items: { type: 'string' } },
@@ -247,8 +204,6 @@ log(`Épica en ${epic.file}, sin promover: revisala, promoví una historia a un
247
204
 
248
205
  return finish({
249
206
  services: services.length,
250
- verified: green,
251
- broken: broken.length,
252
207
  assumptions: supuestos,
253
208
  humanActions: acciones,
254
209
  epic: epic.file,
@@ -14,6 +14,7 @@ const VALUED_FLAGS = new Set([
14
14
  // —un agente, típicamente— recibía texto sin ninguna señal de que su bandera no existía.
15
15
  const FLAGS = {
16
16
  init: ['--name', '--mode', '--force', '--runner', '--integration', '--install', '--no-install'],
17
+ scan: ['--json'],
17
18
  check: ['--json'],
18
19
  tree: ['--json', '--no-color'],
19
20
  context: ['--json'],
package/engine/cli/ops.js CHANGED
@@ -14,6 +14,7 @@ const F = require('../core/files')
14
14
  const O = require('../core/ownership')
15
15
  const CL = require('../core/changelog')
16
16
  const M = require('../core/manifest')
17
+ const SC = require('../core/scan')
17
18
  const C = require('../config/validate')
18
19
  const T = require('../teams/registry')
19
20
  const AG = require('../agents/catalog')
@@ -35,6 +36,7 @@ function usage() {
35
36
  console.log(`Uso:
36
37
  ops init [destino] [--name <nombre>] [--mode embedded|sidecar] [--force]
37
38
  [--runner claude|codex|gemini|antigravity] [--integration <proveedor>] [--install|--no-install]
39
+ ops scan [workspace] [--json]
38
40
  ops check <planning-dir> [--json]
39
41
  ops tree <planning-dir> [--no-color] [--json]
40
42
  ops context <planning-dir> [--json]
@@ -332,6 +334,34 @@ async function init(target, cli) {
332
334
  if (resultado.error) fail(`${resultado.error}: la instancia quedó creada pero todavía no funciona.`)
333
335
  }
334
336
 
337
+ // Qué hay en el workspace, antes de que nadie razone sobre él. La raíz ops se saltea: no es un servicio
338
+ // del proyecto, y su `package.json` sólo declara el motor.
339
+ function scan(target, cli) {
340
+ // Sólo el sidecar tiene su workspace afuera: la instancia es hija de la carpeta donde vive el código.
341
+ // Cualquier otro modo —embedded, y este mismo repositorio, que es `toolkit`— escanea donde está
342
+ // parado. Confundirlos hacía que un `scan` sin argumentos se fuera a recorrer la carpeta de al lado.
343
+ const sidecar = O.mode(process.cwd()) === 'sidecar'
344
+ const root = path.resolve(target || (sidecar ? path.join(process.cwd(), '..') : '.'))
345
+ const result = SC.scan(root, sidecar ? process.cwd() : '')
346
+ if (cli.has('--json')) return console.log(JSON.stringify(result, null, 2))
347
+ console.log(`workspace ${result.root}`)
348
+ if (result.rootManifests.length) {
349
+ console.log(`. ${result.rootManifests.join(', ')}${comandos(result.rootCommands)}`)
350
+ }
351
+ for (const service of result.services) {
352
+ console.log(`${service.path} [${service.runtimes.join(', ')}]${comandos(service.commands)}`)
353
+ }
354
+ const total = result.services.length + (result.rootManifests.length ? 1 : 0)
355
+ console.log(`${total} candidato(s). Cuál es el producto y cuál quedó muerto lo decide una persona.`)
356
+ }
357
+
358
+ // Sólo lo declarado y de dónde salió: un comando inventado se lee igual que uno real.
359
+ function comandos(commands) {
360
+ const entries = Object.entries(commands || {})
361
+ if (!entries.length) return ' — sin comandos declarados'
362
+ return ` — ${entries.map(([kind, value]) => `${kind}: ${value.command} (${value.source})`).join(', ')}`
363
+ }
364
+
335
365
  function check(dir, cli) {
336
366
  const root = path.resolve(dir || '.')
337
367
  const errors = []
@@ -1138,6 +1168,7 @@ async function run(cli) {
1138
1168
  }
1139
1169
  const arg = cli.positional
1140
1170
  if (command === 'init') await init(arg[1], cli)
1171
+ else if (command === 'scan') scan(arg[1], cli)
1141
1172
  else if (command === 'check') check(arg[1], cli)
1142
1173
  else if (command === 'tree') tree(arg[1], cli)
1143
1174
  else if (command === 'context') context(arg[1], cli)
@@ -0,0 +1,120 @@
1
+ 'use strict'
2
+
3
+ // Qué hay en el workspace, resuelto por código y no por un modelo. Existe porque el arranque empezaba
4
+ // pidiéndole a un agente que «inventariara el repositorio»: en una carpeta vacía eso gastó doce minutos
5
+ // para no encontrar nada. Recorrer directorios y leer manifiestos es determinista, y lo que no lo es
6
+ // —qué de todo esto es el producto, qué está muerto— recién vale la pena preguntárselo a un modelo
7
+ // cuando esta lista existe.
8
+
9
+ const fs = require('node:fs')
10
+ const path = require('node:path')
11
+
12
+ // Lo que nunca es un servicio del proyecto. `node_modules` es el que hace la diferencia entre
13
+ // milisegundos y minutos: adentro hay un manifiesto por dependencia. Los directorios ocultos se saltean
14
+ // enteros —regla, no lista—: ahí viven la configuración del runner, el banco de evaluación y las cachés,
15
+ // y un servicio del producto no se esconde detrás de un punto.
16
+ const IGNORED = new Set([
17
+ 'node_modules', 'vendor', 'dist', 'build', 'target', 'out', 'coverage', 'venv', '__pycache__', 'tmp',
18
+ ])
19
+
20
+ const skippable = (name) => name.startsWith('.') || IGNORED.has(name)
21
+
22
+ // Un servicio anidado más hondo que esto es una excepción, y recorrer el árbol entero para encontrarlo
23
+ // cuesta más que declararlo a mano en `AGENTS.md`.
24
+ const DEPTH = 3
25
+
26
+ const MANIFESTS = [
27
+ { file: 'package.json', runtime: 'node' },
28
+ { file: 'go.mod', runtime: 'go' },
29
+ { file: 'pyproject.toml', runtime: 'python' },
30
+ { file: 'requirements.txt', runtime: 'python' },
31
+ { file: 'Cargo.toml', runtime: 'rust' },
32
+ { file: 'composer.json', runtime: 'php' },
33
+ { file: 'pom.xml', runtime: 'java' },
34
+ { file: 'build.gradle', runtime: 'java' },
35
+ { file: 'Gemfile', runtime: 'ruby' },
36
+ { file: 'Makefile', runtime: 'make' },
37
+ { file: 'docker-compose.yml', runtime: 'compose' },
38
+ { file: 'docker-compose.yaml', runtime: 'compose' },
39
+ { file: 'Dockerfile', runtime: 'docker' },
40
+ ]
41
+
42
+ // Sólo lo declarado, con su archivo: un comando inventado se lee igual que uno real, y el primer Verify
43
+ // de una tarea es donde se descubre que no existe.
44
+ function npmScripts(file) {
45
+ try {
46
+ const scripts = JSON.parse(fs.readFileSync(file, 'utf8')).scripts || {}
47
+ return ['test', 'lint', 'build'].reduce((found, key) => (
48
+ scripts[key] ? { ...found, [key]: { command: `npm run ${key}`, source: 'package.json' } } : found
49
+ ), {})
50
+ } catch { return {} }
51
+ }
52
+
53
+ function makeTargets(file) {
54
+ try {
55
+ const text = fs.readFileSync(file, 'utf8')
56
+ return ['test', 'lint', 'build'].reduce((found, key) => (
57
+ new RegExp(`^${key}:`, 'm').test(text)
58
+ ? { ...found, [key]: { command: `make ${key}`, source: 'Makefile' } }
59
+ : found
60
+ ), {})
61
+ } catch { return {} }
62
+ }
63
+
64
+ function commandsOf(dir) {
65
+ const packageJson = path.join(dir, 'package.json')
66
+ const makefile = path.join(dir, 'Makefile')
67
+ return {
68
+ // El Makefile gana sobre los scripts cuando los dos existen: el que envuelve al otro es el que el
69
+ // proyecto quiere que se corra.
70
+ ...(fs.existsSync(packageJson) ? npmScripts(packageJson) : {}),
71
+ ...(fs.existsSync(makefile) ? makeTargets(makefile) : {}),
72
+ }
73
+ }
74
+
75
+ function manifestsOf(dir) {
76
+ return MANIFESTS.filter((entry) => fs.existsSync(path.join(dir, entry.file)))
77
+ }
78
+
79
+ // Servicios candidatos bajo `root`, sin entrar en `skip` —típicamente la raíz ops, que no es un
80
+ // servicio del proyecto—. El resultado es una lista, no un veredicto: decidir cuál es el producto y
81
+ // cuál quedó muerto sigue siendo trabajo de una persona o de un cargo.
82
+ function services(root, skip = '') {
83
+ const found = []
84
+ const excluded = skip ? path.resolve(skip) : ''
85
+ const walk = (dir, depth) => {
86
+ const manifests = manifestsOf(dir)
87
+ if (manifests.length && path.resolve(dir) !== path.resolve(root)) {
88
+ found.push({
89
+ path: path.relative(root, dir).split(path.sep).join('/'),
90
+ runtimes: manifests.map((entry) => entry.runtime),
91
+ manifests: manifests.map((entry) => entry.file),
92
+ commands: commandsOf(dir),
93
+ })
94
+ }
95
+ if (depth >= DEPTH) return
96
+ let entries = []
97
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
98
+ for (const entry of entries) {
99
+ if (!entry.isDirectory() || skippable(entry.name)) continue
100
+ const child = path.join(dir, entry.name)
101
+ if (excluded && path.resolve(child) === excluded) continue
102
+ walk(child, depth + 1)
103
+ }
104
+ }
105
+ walk(root, 0)
106
+ return found
107
+ }
108
+
109
+ // El primer nivel del workspace también puede ser un solo proyecto sin subcarpetas: se reporta aparte
110
+ // para no confundir «un servicio en la raíz» con «no hay nada».
111
+ function scan(root, skip = '') {
112
+ return {
113
+ root: path.resolve(root),
114
+ rootManifests: manifestsOf(root).map((entry) => entry.file),
115
+ rootCommands: commandsOf(root),
116
+ services: services(root, skip),
117
+ }
118
+ }
119
+
120
+ module.exports = { scan, services, IGNORED, DEPTH }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",