@ingeniomaps/cauce 0.95.0 → 0.97.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.
@@ -14,7 +14,7 @@ const SC = require('../core/scan')
14
14
  const OB = require('../core/onboarding')
15
15
  const IN = require('./instance')
16
16
  const ST = require('../planning/state')
17
- const { fail, opsRoot } = require('./io')
17
+ const { fail, opsRoot, USAGE, REFUSED } = require('./io')
18
18
 
19
19
  // Cuántos servicios se listan en pantalla antes de recortar. El resto sigue en `--json`, que es lo que
20
20
  // consume el recorrido de arranque: recortar la lista es para leerla, no para acotar lo que se sabe.
@@ -45,7 +45,7 @@ const INTEGRATION = {
45
45
  && fs.existsSync(path.join(root, 'integrations', provider))
46
46
  if (!fromCauce && !own) {
47
47
  fail(`Cauce no trae un adaptador para ${provider}. Uno propio vive en integrations/${provider}/ y se `
48
- + `registra en integrations/config.json con "adapter": "./adapter.js"; con eso, enable lo conecta.`, 2)
48
+ + `registra en integrations/config.json con "adapter": "./adapter.js"; con eso, enable lo conecta.`, USAGE)
49
49
  }
50
50
  // Habilitar no es inicializar: repone lo que falte y conserva lo que ya esté. Una instancia que
51
51
  // trae el andamiaje de una versión anterior —o que ya tiene snapshots— sólo quiere el interruptor.
@@ -86,7 +86,7 @@ const INTEGRATION = {
86
86
  const result = I.validate(root, provider || '')
87
87
  for (const warning of result.warnings) console.warn(`⚠ ${warning}`)
88
88
  for (const error of result.errors) console.error(`✗ ${error}`)
89
- if (result.errors.length) fail(`${result.errors.length} error(es) de integración`)
89
+ if (result.errors.length) fail(`${result.errors.length} error(es) de integración`, REFUSED)
90
90
  console.log(`✓ integraciones válidas${provider ? `: ${provider}` : ''}`)
91
91
  },
92
92
  },
@@ -203,14 +203,14 @@ function onboard(rootArg, cli, runner = '') {
203
203
  function providerRegistry(root) {
204
204
  const file = path.join(root, 'integrations', 'config.json')
205
205
  try { return { file, config: JSON.parse(fs.readFileSync(file, 'utf8')) } } catch (error) {
206
- return fail(`integrations/config.json ilegible: ${error.message}`)
206
+ return fail(`integrations/config.json ilegible: ${error.message}`, USAGE)
207
207
  }
208
208
  }
209
209
 
210
210
  function switchProvider(root, provider, enabled) {
211
211
  const { file, config } = providerRegistry(root)
212
212
  if (!config.providers || !config.providers[provider]) {
213
- fail(`${provider} no está en integrations/config.json.`)
213
+ fail(`${provider} no está en integrations/config.json.`, USAGE)
214
214
  }
215
215
  config.providers[provider].enabled = enabled
216
216
  F.atomicWriteJson(file, config)
@@ -218,8 +218,8 @@ function switchProvider(root, provider, enabled) {
218
218
 
219
219
  async function integration(action, rootArg, provider, key, cli) {
220
220
  const step = INTEGRATION[action]
221
- if (!step) fail(`Acción de integración desconocida: ${action || '(vacía)'}`, 2)
222
- if (step.missing && (!provider || (step.needsKey && !key))) fail(step.missing, 2)
221
+ if (!step) fail(`Acción de integración desconocida: ${action || '(vacía)'}`, USAGE)
222
+ if (step.missing && (!provider || (step.needsKey && !key))) fail(step.missing, USAGE)
223
223
  await step.run(path.resolve(rootArg || '.'), provider, key, cli)
224
224
  }
225
225
 
@@ -245,7 +245,7 @@ function automation(action, rootArg, runnerName, cli) {
245
245
  if (action === 'check') {
246
246
  const errors = A.check(root)
247
247
  for (const error of errors) console.error(`✗ ${error}`)
248
- if (errors.length) fail(`${errors.length} error(es) de automatización`)
248
+ if (errors.length) fail(`${errors.length} error(es) de automatización`, REFUSED)
249
249
  console.log(
250
250
  `✓ automatización válida: ${A.GUARD_NAMES.length} guards, ${A.RUNNER_NAMES.length} adaptadores`,
251
251
  )
@@ -260,22 +260,22 @@ function automation(action, rootArg, runnerName, cli) {
260
260
  }
261
261
  if (action === 'doctor') {
262
262
  let result
263
- try { result = A.doctor(root, runnerName) } catch (error) { fail(error.message, 2) }
263
+ try { result = A.doctor(root, runnerName) } catch (error) { fail(error.message, USAGE) }
264
264
  if (result.errors.length) {
265
- fail(`${runnerName}: ${result.errors.length} error(es), ${result.warnings.length} advertencia(s)`)
265
+ fail(`${runnerName}: ${result.errors.length} error(es), ${result.warnings.length} advertencia(s)`, REFUSED)
266
266
  }
267
267
  console.log(`✓ ${runnerName}: adaptador operativo (${result.warnings.length} advertencia(s))`)
268
268
  return
269
269
  }
270
270
  if (action === 'uninstall') {
271
- try { A.uninstall(root, runnerName, console) } catch (error) { fail(error.message, 2) }
271
+ try { A.uninstall(root, runnerName, console) } catch (error) { fail(error.message, USAGE) }
272
272
  console.log(' la instancia sigue en pie: borrar la carpeta ops es una decisión aparte.')
273
273
  return
274
274
  }
275
275
  if (action === 'install') {
276
276
  let runner
277
277
  const force = cli.has('--force')
278
- try { runner = A.install(root, runnerName, console, { force }) } catch (error) { fail(error.message, 2) }
278
+ try { runner = A.install(root, runnerName, console, { force }) } catch (error) { fail(error.message, USAGE) }
279
279
  if (runnerName === 'codex') {
280
280
  console.log(' Codex deja los hooks nuevos sin correr hasta que los confíes: abrí una sesión')
281
281
  console.log(' y usá /hooks para revisarlos y marcarlos como confiables.')
@@ -284,20 +284,20 @@ function automation(action, rootArg, runnerName, cli) {
284
284
  console.log(` ${runnerName} no expone hooks nativos; aplica guards como prechecks.`)
285
285
  }
286
286
  const result = A.doctor(root, runnerName)
287
- if (result.errors.length) fail(`${runnerName}: instalación incompleta`)
287
+ if (result.errors.length) fail(`${runnerName}: instalación incompleta`, REFUSED)
288
288
  console.log(`✓ ${runnerName}: adaptador operativo (${result.warnings.length} advertencia(s))`)
289
289
  return
290
290
  }
291
- fail(`Acción de automatización desconocida: ${action || '(vacía)'}`, 2)
291
+ fail(`Acción de automatización desconocida: ${action || '(vacía)'}`, USAGE)
292
292
  }
293
293
 
294
294
  function secrets(action, rootArg) {
295
- if (action !== 'check') fail(`Acción de secretos desconocida: ${action || '(vacía)'}`, 2)
295
+ if (action !== 'check') fail(`Acción de secretos desconocida: ${action || '(vacía)'}`, USAGE)
296
296
  const result = SE.check(opsRoot(rootArg))
297
297
  if (!result.declared) return console.log(`Sin ${SE.DECLARATION}: no hay contrato de secretos que comprobar.`)
298
298
  for (const warning of result.warnings) console.log(`⚠ ${warning}`)
299
299
  for (const error of result.errors) console.error(`✗ ${error}`)
300
- if (result.errors.length) fail(`${result.errors.length} error(es) en el contrato de secretos`)
300
+ if (result.errors.length) fail(`${result.errors.length} error(es) en el contrato de secretos`, REFUSED)
301
301
  console.log(`✓ contrato de secretos: ${result.current} servicio(s) al día`)
302
302
  }
303
303
 
@@ -15,7 +15,7 @@ const ST = require('../planning/state')
15
15
  const CL = require('../planning/claims')
16
16
  const R = require('../core/repos')
17
17
  const O = require('../core/ownership')
18
- const { fail, planningRoot } = require('./io')
18
+ const { fail, planningRoot, USAGE, REFUSED } = require('./io')
19
19
 
20
20
  const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
21
21
 
@@ -34,38 +34,38 @@ function existing(repo, branch) {
34
34
 
35
35
  function worktree(dir, slug, cli) {
36
36
  const root = planningRoot(dir)
37
- if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`', 2)
37
+ if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`', USAGE)
38
38
  const state = ST.snapshot(root)
39
39
  const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
40
- if (!task) return fail(`${slug} no está en BACKLOG: sólo se prepara trabajo ya promovido.`, 2)
40
+ if (!task) return fail(`${slug} no está en BACKLOG: sólo se prepara trabajo ya promovido.`, USAGE)
41
41
 
42
42
  // No reserva —eso es `claim`— pero se niega a montar sobre lo de otro: preparar un árbol para una
43
43
  // tarea ajena es trabajo que se va a tirar, y el aviso cuesta menos que descubrirlo después.
44
44
  const taken = state.claims.find((one) => one.slug === slug)
45
45
  if (taken && taken.runner !== CL.runner()) {
46
- return fail(`${slug} la tomó ${taken.owner}; preparar un árbol para su tarea no ayuda a nadie.`)
46
+ return fail(`${slug} la tomó ${taken.owner}; preparar un árbol para su tarea no ayuda a nadie.`, REFUSED)
47
47
  }
48
48
 
49
49
  const candidatos = R.reposFor(path.join(root, '..'), task.service)
50
50
  if (candidatos.length > 1) {
51
51
  return fail(`${task.service || '.'} existe en más de un repositorio (${candidatos.join(', ')}), así que `
52
- + 'no puedo saber cuál. Escribí un `service:` que sólo exista en uno.', 2)
52
+ + 'no puedo saber cuál. Escribí un `service:` que sólo exista en uno.', USAGE)
53
53
  }
54
54
  const repo = candidatos[0]
55
55
  if (!repo) {
56
56
  return fail(`no encontré el repositorio de ${task.service || '(sin service)'}: revisá workspaceRoots `
57
- + 'en ops.config.json y que la ruta del servicio exista.', 2)
57
+ + 'en ops.config.json y que la ruta del servicio exista.', USAGE)
58
58
  }
59
59
 
60
60
  const branch = CL.branchOf(slug)
61
61
  const already = existing(repo, branch)
62
62
  const target = already || path.join(path.dirname(repo), `${path.basename(repo)}-${slug}`)
63
63
  if (!already) {
64
- if (fs.existsSync(target)) return fail(`${target} ya existe y no es un árbol de esta rama.`)
64
+ if (fs.existsSync(target)) return fail(`${target} ya existe y no es un árbol de esta rama.`, REFUSED)
65
65
  const hasBranch = git(repo, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`).status === 0
66
66
  const args = hasBranch ? ['worktree', 'add', target, branch] : ['worktree', 'add', '-b', branch, target]
67
67
  const added = git(repo, ...args)
68
- if (added.status !== 0) return fail(`git worktree add falló: ${(added.stderr || '').trim()}`)
68
+ if (added.status !== 0) return fail(`git worktree add falló: ${(added.stderr || '').trim()}`, REFUSED)
69
69
  }
70
70
 
71
71
  if (cli.has('--json')) {
@@ -164,9 +164,16 @@ function engineAt(root, relative = '') {
164
164
  // de copiarse. Se reconocen por contener `system/`, que es el espacio del toolkit y no algo que un
165
165
  // proyecto deba crear. Una sola implementación para las dos, o divergen.
166
166
  function packageDir(root, name) {
167
+ // La raíz declarada va también acá, y no sólo en `packagePath`. Las dos contestan dónde está el paquete
168
+ // sobre la misma instancia y desde el 158 contestaban distinto: en el layout documentado —`npm install`
169
+ // en la carpeta de la empresa y la instancia adentro— el motor se resolvía y el catálogo no, así que
170
+ // `check` pasaba en verde y `agents list` devolvía una lista vacía con exit 0, indistinguible de «esta
171
+ // instancia no tiene cargos». `evaluate` era peor: culpaba a un `SKILL.md` que falta (caso 171).
172
+ const above = declaredRoot(root)
167
173
  const candidates = [
168
174
  path.join(root, 'node_modules', '@ingeniomaps', 'cauce', name),
169
175
  path.join(root, name),
176
+ ...(above ? [path.join(above, 'node_modules', '@ingeniomaps', 'cauce', name)] : []),
170
177
  ]
171
178
  return candidates.find((dir) => fs.existsSync(path.join(dir, 'system'))
172
179
  || fs.existsSync(path.join(dir, 'roles', 'system'))) || ''
@@ -45,6 +45,20 @@ const COMANDO = String.raw`$|[;&|)'"\`]`
45
45
  // de frenar trabajo legítimo nombraba una violación que no estaba.
46
46
  const MISMO = String.raw`[^;&|\n]`
47
47
 
48
+ // Si algún remoto alcanza al commit que está en HEAD. Lectura local e inocua: `branch -r` mira lo que ya
49
+ // está en `refs/remotes/`, no habla con ningún servidor. Un `fetch` que nadie corrió deja la respuesta
50
+ // desactualizada hacia el lado seguro — cree que no está publicado algo que sí lo está—, y por eso esto
51
+ // acota un bloqueo y no autoriza nada: lo que decide publicar sigue siendo `push`, que mira otra cosa.
52
+ //
53
+ // Sin git, sin repositorio o con el comando fallando, la respuesta es **sí**: cerrado por defecto (R27).
54
+ // No saber si algo se publicó no es lo mismo que saber que no.
55
+ function publishedHead(input) {
56
+ const cwd = (input && input.cwd) || process.cwd()
57
+ const result = spawnSync('git', ['branch', '-r', '--contains', 'HEAD'], { cwd, encoding: 'utf8' })
58
+ if (result.status !== 0) return true
59
+ return Boolean(result.stdout.trim())
60
+ }
61
+
48
62
  // Un mensaje de commit es dato, no código. `git commit -m "fix: bloquear git push --force"` disparaba
49
63
  // el guard de publicación, y lo mismo `rm -rf /` nombrado en una explicación; con el heredoc que se usa
50
64
  // para un mensaje largo, el cuerpo entero entra en el comando, así que la línea que arregla esto no se
@@ -80,13 +94,22 @@ function destructive(input) {
80
94
  publish(input, command)
81
95
  const rules = [
82
96
  [/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales.", true],
83
- // R8 lo prohíbe sin excepción configurable y ningún guard lo miraba: `grep -rn amend engine/hooks/`
84
- // no devolvía una línea. Se bloquea por política y no por daño —un `--amend` sobre algo que nadie vio
85
- // no rompe nada—, así que el mensaje manda a lo que sí corresponde: otro commit.
86
- [
97
+ // Lo que R8 protege es la historia que **otro ya leyó**, y el motor ya hace esa distinción para el
98
+ // push: publicar se autoriza, reescribir lo publicado no. Acá se hace la misma, mirando si algún
99
+ // remoto alcanza a HEAD.
100
+ //
101
+ // Antes se bloqueaba siempre, «por política y no por daño», y eso no impedía el resultado sino el
102
+ // comando que lo nombra: `git reset --soft HEAD~1` y volver a commitear produce exactamente lo mismo,
103
+ // no lo frena ningún guard y no deja constancia de nada. Una regla que se cumple mejor esquivándola se
104
+ // termina esquivando siempre (caso 178).
105
+ //
106
+ // Y no lleva salida por chat ni publicado ni sin publicar: sin publicar no la necesita, y publicado es
107
+ // la misma reescritura que el force-push, que tampoco la tiene.
108
+ ...(publishedHead(input) ? [[
87
109
  new RegExp(String.raw`\bgit\s+commit\b${MISMO}*\s--amend\b`),
88
- "'git commit --amend' reescribe un commit ya creado. R8 pide uno nuevo en su lugar.",
89
- ],
110
+ "'git commit --amend' sobre un commit ya publicado reescribe historia que otro leyó. R8 lo "
111
+ + 'prohíbe: hacé otro commit encima.',
112
+ ]] : []),
90
113
  [/\bgit\s+clean\s+-[^\s]*f/, "'git clean -f' borra archivos sin seguimiento.", true],
91
114
  // `git checkout -- .` destruye lo mismo que `reset --hard` y sin recuperación, pero se escribe como
92
115
  // una limpieza. Se bloquea sólo la forma ancha —`.`, `*`, `:/`, o sin ruta—: revertir un archivo
@@ -383,6 +383,25 @@ function validateState({
383
383
  if (!row.valid) {
384
384
  errors.push(`HUMAN_ACTIONS ${row.task}: estado "${row.state}" fuera de `
385
385
  + `${P.HUMAN_ACTION_STATES.join(' | ')}; mientras no se entienda, la tarea queda bloqueada`)
386
+ continue
387
+ }
388
+ // La primera columna es a la vez lo que una persona lee y la clave con la que el motor bloquea:
389
+ // la selección de tarea (`state.js`) arma su conjunto de bloqueadas con ella tal cual. Esa doble
390
+ // función es libre a propósito —el molde manda
391
+ // nombrar la épica o el recorrido cuando la tarea todavía no existe— así que lo que no se puede
392
+ // recortar es la libertad, y lo que sí se puede es la forma intermedia: la celda que **menciona** una
393
+ // tarea de la cola sin ser su slug.
394
+ //
395
+ // Es la peor de las tres porque promete un bloqueo que no ocurre, y nada lo dice: la fila se escribe
396
+ // sin error, sale en `ops context` bajo HUMAN como si estuviera registrada, y la tarea se sigue
397
+ // ofreciendo. `**slug: de qué se trata**` es la que sale natural, porque esta tabla la lee una
398
+ // persona. Sin esto la ausencia no deja rastro, que es la forma de R15 aplicada a un mecanismo.
399
+ if (backlogSlugs.has(row.task)) continue
400
+ const casi = [...backlogSlugs].find((slug) => new RegExp(`\\b${slug}\\b`).test(row.task))
401
+ if (casi) {
402
+ errors.push(`HUMAN_ACTIONS: la fila "${row.task}" nombra a ${casi} y no bloquea nada, porque el `
403
+ + `motor bloquea por la primera columna exacta. Dejá "${casi}" sola ahí y contá el resto en la `
404
+ + 'acción, o nombrá la épica o el recorrido si lo que se frena no es esa tarea')
386
405
  }
387
406
  }
388
407
 
@@ -176,6 +176,10 @@ function readCast(rest) {
176
176
  // devolvía `MAX` con `check` en verde, que es la forma cara del error —la tarea se lee completa y no
177
177
  // lo está—. Cierra el `_` que markdown cerraría: el que no está entre caracteres de palabra.
178
178
  const ACCEPTANCE = /_Aceptaci[oó]n:\s*(.*?\S)_(?![A-Za-z0-9])/i
179
+ // Los paréntesis del contrato de una línea de tarea. Se enumeran por su clave y no como «cualquier
180
+ // paréntesis» para no llevarse puesta una aclaración de la prosa: la descripción usa paréntesis igual
181
+ // que cualquier texto, y el criterio `(→ C1)` va acá porque `criteria` ya lo extrae.
182
+ const MARKERS = /\((?:→|->|criterios?\s|epic:|service:|cast:|depende:|sin partir:)[^)]*\)/gi
179
183
 
180
184
  // Cuántas condiciones tiene una aceptación escrita en prosa. Estuvo mucho tiempo sin contarse con una
181
185
  // razón buena —contar condiciones en una frase es una lectura, y un número inventado es peor que
@@ -207,6 +211,19 @@ function taskFromLine(line) {
207
211
  epic: ((rest.match(/\(epic:\s*(\d{3})\)/) || [])[1] || ''),
208
212
  service: ((rest.match(/\(service:\s*([^)]+)\)/) || [])[1] || '').trim(),
209
213
  acceptance,
214
+ // Lo que la aceptación no puede decir y alguien ya decidió: dónde vive un símbolo, qué queda fuera de
215
+ // alcance, con qué se produce la evidencia. Por contrato la aceptación describe estado observable del
216
+ // producto, así que una decisión de diseño no cabe ahí — y hasta acá tampoco salía del BACKLOG: el
217
+ // texto vivía en `rest` y se descartaba.
218
+ //
219
+ // Sin ella el que planifica vuelve a decidir lo ya decidido, y decide distinto. La crítica **sí** abre
220
+ // el BACKLOG y bloquea el plan citando la línea palabra por palabra: una compuerta juzga contra un
221
+ // texto que la otra no recibió. Dos corridas reales se pagaron enteras para descubrirlo, 1,10 M y
222
+ // 815 k tokens, la segunda con sus cuatro objeciones diciendo lo mismo (caso 177).
223
+ //
224
+ // Se recorta la aceptación y los marcadores del contrato porque cada uno ya tiene su campo: repetirlos
225
+ // acá los pone dos veces en el prompt de cada fase, que es lo que R16 cobra una vez por etapa.
226
+ description: rest.replace(ACCEPTANCE, '').replace(MARKERS, '').replace(/\s+/g, ' ').trim(),
210
227
  conditions: acceptanceConditions(acceptance),
211
228
  criteria: criteriaRefs(rest),
212
229
  // De qué otras tareas depende. El orden del BACKLOG alcanzaba mientras hubiera un runner: con dos,
@@ -388,6 +405,23 @@ function readWip(dir, runner) {
388
405
  return parseWip(read(path.join(dir, 'wip', `${name}.md`)), name)
389
406
  }
390
407
 
408
+ // Si el checkpoint de hito sigue frenando, leído del archivo y no de que el archivo esté. Es la misma
409
+ // forma que el WIP de acá arriba —`status: IDLE` es un estado escrito— y la razón es R28: un centinela
410
+ // cuya única información es existir obliga a que el borrado sea parte de la resolución, y esa es una
411
+ // convención que alguien va a olvidar. Cuando se olvida, quien revisa lee que el hito ya se revisó
412
+ // mientras el mecanismo sigue leyendo que el archivo está, y la corrida siguiente muere en la puerta de
413
+ // entrada habiendo cargado el estado entero.
414
+ //
415
+ // Cerrado por defecto (R27): frena salvo que diga `resuelta`. Un archivo de una instancia anterior no
416
+ // trae `status`, y abrirlo por eso destrabaría en silencio al actualizar, que es la quita escrita como
417
+ // agregado que R9 nombra. El vocabulario es el mismo de `HUMAN_ACTIONS.md` a propósito: es el mismo acto
418
+ // —una persona contesta— y dos palabras para eso serían dos convenciones que aprender.
419
+ function checkpointHolds(dir) {
420
+ const text = read(path.join(dir, 'AWAITING_REVIEW.md'))
421
+ if (!text) return false
422
+ return !/^status:\s*resuelta\b/mi.test(text)
423
+ }
424
+
391
425
  // Todos los que hay. Lo pregunta `check`, que juzga si cada plan apunta a una tarea que existe, y `tree`,
392
426
  // que muestra qué está en vuelo: las dos son preguntas sobre la instancia y no sobre quien pregunta.
393
427
  function readWips(dir) {
@@ -436,6 +470,7 @@ module.exports = {
436
470
  EPIC_STATES, HUMAN_ACTION_STATES, LANES, MILESTONE_HEADING, STOP_REASONS,
437
471
  TASK_LINE, TASK_LINE_ANY_LANE,
438
472
  read, section, withoutComments, frontmatter, readEpics, readBacklog, readDone, readWip, readWips, wipName,
473
+ checkpointHolds,
439
474
  acceptanceConditions, tableRows, taskFromLine,
440
475
  readInbox, inboxHeads, readHumanActions,
441
476
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.95.0",
3
+ "version": "0.97.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -3,7 +3,13 @@
3
3
  ## R8 — Un commit por naturaleza
4
4
 
5
5
  Stagear rutas explícitas, revisar el diff staged y crear un Conventional Commit en inglés. No usar
6
- `git add .`, `git add -A`, amend, force ni trailers de IA.
6
+ `git add .`, `git add -A`, force ni trailers de IA.
7
+
8
+ **Y no reescribir historia que otro ya leyó.** Lo que se protege es eso, no el comando: un `--amend`
9
+ sobre un commit publicado es la misma reescritura que un force-push y se frena igual; sobre uno que no
10
+ salió de tu máquina es la corrección, y prohibirlo no impide el resultado —`git reset --soft HEAD~1` y
11
+ volver a commitear produce exactamente lo mismo— sino el comando que lo nombra. Una regla que se cumple
12
+ mejor esquivándola se termina esquivando siempre.
7
13
 
8
14
  Dónde corta un commit lo decide la naturaleza del diff, no su tamaño ni un conteo. Una tarea suele
9
15
  tener una sola, y por eso un commit por tarea es lo habitual; cuando tiene dos, se hacen dos. Un
@@ -90,7 +96,8 @@ Push, PR, merge, tags, deploy y rollback requieren la autorización configurada
90
96
  De esos seis, el motor comprueba uno: el push, contra `runner.allowPush` —que no llega a la rama viva
91
97
  sin `runner.pushToLiveBranches`, ni a un subagente— o contra la orden que la persona da en el chat
92
98
  nombrando el remoto y la rama. Reescribir historia publicada
93
- no entra en esa autorización y se frena siempre, igual que `--amend`. Los otros cinco no tienen una
99
+ no entra en esa autorización y se frena siempre — también cuando la reescritura es un `--amend`, que el
100
+ guard distingue mirando si algún remoto alcanza al commit. Los otros cinco no tienen una
94
101
  forma reconocible en un comando —un deploy es `kubectl`, `terraform`, un script o un botón— y los
95
102
  sostiene esta regla y el review, no un guard.
96
103