SLOPSHOPPER

marchas

Comando y banda de marcha para la sesion de main.

newbandcommandtoastpromptprocess
v0.1.0MITupdated 2026-10-16Gagaismo/orca-stack/mods/marchas
A shopper browsing a rack in a slop shop
README

Marchas

Marchas guarda cada cambio explícito de marcha asociado al ID de la sesión. Los registros de más de 30 días se eliminan la próxima vez que el mod inicia una sesión. La retención de 30 días se cuenta desde el comando /marcha <marcha> más reciente; una sesión retomada cuya clave haya vencido empieza en Medium.

Las versiones anteriores guardaban solo el nombre de la marcha, sin fecha. Esas entradas heredadas se conservan porque no se puede determinar su antigüedad con seguridad; Medium heredado se trata como si no se hubiera cambiado la marcha, porque las versiones anteriores también lo guardaban por defecto.

Después de una compactación, Marchas restaura la postura actual cuando se ejecuta classic.SessionStart. Si ese hook no está disponible, la banda recuerda que hay que repetir /marcha <marcha>.

Volver con rewind a un punto anterior al cambio de marcha puede quitar el mensaje de postura aunque el store del plugin y la banda sigan mostrando la marcha más nueva. Este comportamiento se informa en el issue #163 y las APIs de sesión actuales no lo reconcilian.

Esfuerzo

Marchas ajusta el esfuerzo de cada solicitud de main sin cambiar el esfuerzo configurado para la sesión: Lazy pone un máximo de medium, Medium no lo reescribe y Proactive pone un mínimo de xhigh. Las solicitudes de subagentes quedan intactas. La respuesta visible de /marcha nombra el límite que aplica.

Un comando /effort <nivel> manual gana hasta el siguiente cambio de marcha y pasa a ser la nueva base, incluso si la persona repite el mismo nivel. /effort ultracode es un cambio de flujo de trabajo, no un ajuste manual del esfuerzo. /model tampoco es un ajuste manual: Marchas vuelve a registrar la base observada para el nuevo modelo. La base y el ajuste manual se guardan junto con la marcha para que sigan vigentes al reanudar la sesión.

El nivel que se elige en el menú de /effort (y el resultado de /effort auto) no le llega al mod: Marchas solo ve el esfuerzo de cada solicitud. Por eso un esfuerzo que cambia cuenta como ajuste manual solo si se abrió el menú o se ejecutó /effort auto después del último /marcha <marcha>; si cambia sin un /effort desde ese cambio de marcha, no es manual y la marcha lo sigue ajustando. Elegir en el menú el mismo nivel que ya estaba no cuenta como ajuste manual, porque el mod no puede distinguirlo de cerrar el menú sin elegir. Queda un caso que el mod no puede distinguir: si elegís un nivel en el menú, cambiás de marcha y después abrís el menú y lo cancelás, el nivel que habías elegido antes se toma como ajuste manual.

Modo autónomo

Marchas avisa en una línea cuando el modo autónomo coincide con algo que cambia lo que pasa sin supervisión. El aviso es una nota del transcript que el modelo no lee; si el transcript la rechaza, sale como toast.

  • /autonomo en Lazy: los items que no estén especificados van a esperar un cambio de marcha.
  • /autonomo en Proactive o con ultracode activo, o /marcha proactive o /effort ultracode con el modo autónomo ya activo: qué puede costar más sin supervisión (la exploración, las consultas y la vigilancia de Proactive; el workflow de ultracode para cada tarea sustantiva, sin límite de concurrencia) y cómo bajarlo (/marcha medium, o apagar ultracode).

Marchas lee el valor isOn de modo-autonomo y se entera de que el modo se encendió cuando ese mod lo escribe, sin depender del orden entre los dos mods. Sigue ultracode con /effort ultracode y con los attachments ultra_effort_enter y ultra_effort_exit: gana el último. Un attachment no avisa, porque al reanudar se vuelven a pedir los del historial. Ultracode dura un proceso, así que una sesión reanudada empieza sin ultracode. Después de /clear el mod no ve ultracode hasta el siguiente /effort ultracode o attachment, y el aviso de Proactive dice que no lo ve. En una organización que administra Claude Code los attachments no le llegan al mod, que entonces solo ve /effort ultracode.

Source 2 files
hooks/register.tsx 374 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Gear } from '../types'
4
5const GEARS: Gear[] = ['lazy', 'medium', 'proactive']
6const COLORS: Record<Gear, string> = { lazy: 'ide', medium: 'success', proactive: 'warning' }
7const KEY_PREFIX = 'marchas:'
8const RETENTION_MS = 30 * 24 * 60 * 60 * 1000
9const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
10type Effort = typeof EFFORTS[number]
11const marker = (gear: Gear): string => {
12  const effort = {
13    lazy: 'esfuerzo m\u00e1ximo: medium',
14    medium: 'sin reescritura de esfuerzo',
15    proactive: 'esfuerzo m\u00ednimo: xhigh',
16  }[gear]
17
18  return `Marcha: ${gear} (${effort})`
19}
20const sessionKey = async ($: EngineInterface): Promise<string> => `marchas:${await $.session.id()}`
21// modo-autonomo's own value: any plugin reads another's `$.state`, only its owner writes it.
22const AUTONOMOUS = { plugin: 'modo-autonomo', key: 'isOn' } as const
23// Ultracode is per process and `$.state` lasts one: unset (after `/clear`) means the mod cannot see it.
24const ULTRACODE = { plugin: 'marchas', key: 'ultracode' } as const
25const LAZY_WARNING = 'Modo aut\u00f3nomo en Lazy: los items que no est\u00e9n especificados van a esperar un cambio de marcha.'
26
27const costWarning = (gear: Gear, ultracode: boolean | undefined): string | undefined => {
28  const proactive = gear === 'proactive'
29  if (!proactive && ultracode !== true) return undefined
30  const costs = [
31    ...(proactive ? ['la exploraci\u00f3n, las consultas y la vigilancia de Proactive'] : []),
32    ...(ultracode === true ? ['el workflow de ultracode para cada tarea sustantiva, sin l\u00edmite de concurrencia'] : []),
33  ]
34  const blind = proactive && ultracode === undefined ? ' No puedo ver si ultracode est\u00e1 activo.' : ''
35  const fixes = [...(ultracode === true ? ['apag\u00e1 ultracode'] : []), ...(proactive ? ['/marcha medium'] : [])]
36
37  return `Modo aut\u00f3nomo, lo que puede costar m\u00e1s sin supervisi\u00f3n: ${costs.join('; ')}.${blind} Para bajarlo: ${fixes.join(' o ')}`
38}
39
40// A transcript notice the model never reads; a toast if the notice is refused.
41const warn = async ($: EngineInterface, text: string | undefined): Promise<void> => {
42  if (text === undefined) return
43  const appended = await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } }).catch(() => undefined)
44  if (appended?.uuid === undefined) $.ui.toast(text)
45}
46
47const autonomous = async ($: EngineInterface): Promise<boolean> => (await $.state.get(AUTONOMOUS)).value === true
48const isGear = (value: unknown): value is Gear => GEARS.includes(value as Gear)
49const isEffort = (value: unknown): value is Effort => EFFORTS.includes(value as Effort)
50type GearState = {
51  gear: Gear
52  shiftedAt: number
53  needsReminder?: boolean
54  effortBaseline?: Effort
55  effortModel?: string
56  effortOverride?: boolean
57  modelChanged?: boolean
58  // `/effort` opened its menu (or ran `auto`) since the last shift: its result never reaches the mod (#194).
59  effortPicker?: boolean
60}
61
62const gearState = (value: unknown): GearState | undefined => {
63  if (typeof value !== 'object' || value === null) return undefined
64  const state = value as Partial<GearState>
65
66  return isGear(state.gear) && typeof state.shiftedAt === 'number'
67    ? {
68        gear: state.gear,
69        shiftedAt: state.shiftedAt,
70        needsReminder: state.needsReminder === true,
71        ...(isEffort(state.effortBaseline) ? { effortBaseline: state.effortBaseline } : {}),
72        ...(typeof state.effortModel === 'string' ? { effortModel: state.effortModel } : {}),
73        effortOverride: state.effortOverride === true,
74        modelChanged: state.modelChanged === true,
75        effortPicker: state.effortPicker === true,
76      }
77    : undefined
78}
79
80const shiftedGear = (value: unknown): Gear | undefined => {
81  const state = gearState(value)
82  if (state !== undefined) return state.gear
83
84  // Older versions wrote Medium at session start, so legacy Medium is ambiguous.
85  return isGear(value) && value !== 'medium' ? value : undefined
86}
87
88const pruneOldKeys = async ($: EngineInterface): Promise<void> => {
89  const now = await $.clock.now()
90  for (const key of await $.store.keys()) {
91    if (!key.startsWith(KEY_PREFIX)) continue
92    const state = gearState(await $.store.get(key))
93    if (state !== undefined && now - state.shiftedAt > RETENTION_MS) {
94      await $.store.delete(key)
95    }
96  }
97}
98
99const posture = (gear: Gear): string => {
100  const opening = [
101    'Este mensaje de marcha reemplaza cualquier postura de marcha anterior.',
102    'En tu pr\u00f3ximo mensaje, empez\u00e1 con una l\u00ednea que nombre la marcha y qu\u00e9 cambia ahora.',
103  ]
104  const body: Record<Gear, string[]> = {
105    lazy: [
106      'El trabajo ya est\u00e1 especificado: el ticket cumple el contrato y lo que queda es despachar, vigilar y mergear (en modo un-agente, implementarlo). Le\u00e9 el ticket, sus comentarios y el estado del PR. Le\u00e9 c\u00f3digo solo hasta donde lo necesiten los chequeos del contrato, incluido el diff del PR frente a los hallazgos del review y los logs de los checks fallidos (en modo un-agente, hasta ubicar el cambio).',
107      'Hac\u00e9 tu propio trabajo inline; los reviews y verificadores requeridos siguen corriendo como subagentes. Corr\u00e9 el verificador independiente y el review de registro en modo un-agente con el esfuerzo que tendr\u00edan sin marcha. Omit\u00ed la investigaci\u00f3n opcional y el desaf\u00edo de premisas de ODD; los disparadores propios del contrato siguen vigentes.',
108      'Asum\u00ed los detalles de ejecuci\u00f3n y decilo; una regla o un criterio ambiguo en el ticket significa que est\u00e1 incompleto, no que haya que asumirlo. Quedate dentro del pedido: inform\u00e1 en una l\u00ednea cualquier problema que veas, pero no propongas seguimientos. Narr\u00e1 una l\u00ednea por evento y cerr\u00e1 breve pero completo. No propongas arena ni swarm.',
109      'Cuando un item deje de estar especificado (haya que escribir o completar su ticket, la exploraci\u00f3n no entre en un solo lote inline, falle la prueba de reanudaci\u00f3n, un requisito se vuelva problem\u00e1tico o aparezca una decisi\u00f3n de dise\u00f1o), decilo en una l\u00ednea y suger\u00ed subir de marcha en vez de estirarlo. Hasta que alguien cambie la marcha, ese item espera y segu\u00eds con el resto; ninguna regla fija se suspende.',
110    ],
111    medium: [
112      'Trabaj\u00e1 seg\u00fan ODD y el contrato.',
113      'Propon\u00e9 arena o swarm cuando un requisito muestre se\u00f1ales de la secci\u00f3n "Arena y swarm" del contrato.',
114    ],
115    proactive: [
116      'Mir\u00e1 m\u00e1s all\u00e1 del pedido. Antes de actuar, hac\u00e9 que subagentes exploren su entorno (llamadores, vecinos, issues abiertos relacionados; primero ADRs y glosario, Engram como pista) para que riesgos y oportunidades aparezcan antes de causar problemas, y reserv\u00e1 tu contexto para orquestar; deleg\u00e1 temprano las unidades independientes.',
117      'Propon\u00e9 investigar cada vez que un dato externo afecte una decisi\u00f3n y desafi\u00e1 cada premisa de alto impacto. Adem\u00e1s de las consultas requeridas, consult\u00e1 a Gentle Shell antes de escribir tickets, antes de despachar una tanda y cuando desaf\u00edes una premisa. Propon\u00e9 seguimientos, arreglos cercanos y tickets que valga la pena escribir en tu informe o en el mapa o issue paraguas, nunca en un ticket por despachar, y nunca crees issues.',
118      'Colabor\u00e1 con los workers de implementaci\u00f3n hasta que abran su PR: vigil\u00e1 sus issues y PRs en busca de preguntas; respond\u00e9 las preguntas de ejecuci\u00f3n donde las hayan hecho y tocales el timbre en su terminal, solo mientras su turno est\u00e9 en curso, con una l\u00ednea que apunte a la respuesta; llev\u00e1 las de producto e interpretaci\u00f3n al humano y transmit\u00ed la respuesta del mismo modo; avisales lo que cambiar\u00eda su trabajo y no pueden ver. Nunca los hagas esperar, nunca corrijas su c\u00f3digo, y dej\u00e1 en paz a los revisores.',
119      'Propon\u00e9 arena o swarm temprano, ante se\u00f1ales m\u00e1s d\u00e9biles, antes de que aparezca un problema.',
120      'Cont\u00e1 qu\u00e9 encontraste m\u00e1s all\u00e1 del pedido y por qu\u00e9 importa. Vigil\u00e1 de cerca y propon\u00e9 un handoff antes de que se llene tu contexto.',
121    ],
122  }
123
124  return [...opening, ...body[gear]].join('\n\n')
125}
126
127const mainCheckout = async ($: EngineInterface): Promise<string | undefined> => {
128  try {
129    const result = await $.process.run(
130      ['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'],
131      { timeoutMs: 10_000 },
132    )
133    if (result.exitCode !== 0) {
134      return undefined
135    }
136    const [gitDir, commonDir, top] = result.stdout.split(/\r?\n/).map(path => path.trim())
137    const slashed = (path: string | undefined): string => (path ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
138    if (gitDir === undefined || commonDir === undefined || slashed(gitDir).toLowerCase() !== slashed(commonDir).toLowerCase()) {
139      return undefined
140    }
141    const common = slashed(commonDir)
142
143    return common.endsWith('/.git') ? common.slice(0, -'/.git'.length) : slashed(top)
144  } catch {
145    return undefined
146  }
147}
148
149const enabled = async ($: EngineInterface): Promise<boolean> => {
150  const root = await mainCheckout($)
151  if (root === undefined) {
152    return false
153  }
154  if (!(await $.fs.exists(`${root}/.orca-stack.json`)) && !(await $.fs.exists(`${root}/.orca-stack.local.json`))) {
155    return false
156  }
157  try {
158    const agentsPath = `${root}/AGENTS.md`
159    if (!(await $.fs.exists(agentsPath))) {
160      return false
161    }
162    const agents = await $.fs.read(agentsPath)
163    const contract = /<!-- orca-stack:begin -->([\s\S]*?)<!-- orca-stack:end -->/i.exec(agents)?.[1]
164
165    return contract !== undefined && /^#{1,6}\s+Marchas\s*$/mi.test(contract)
166  } catch {
167    return false
168  }
169}
170
171const currentGear = async ($: EngineInterface): Promise<Gear> => {
172  const stored = await $.store.get(await sessionKey($))
173
174  return shiftedGear(stored) ?? 'medium'
175}
176
177const saveEffortState = async ($: EngineInterface, value: unknown, changes: Partial<GearState>): Promise<void> => {
178  const state = gearState(value)
179  await $.store.set(await sessionKey($), {
180    gear: shiftedGear(value) ?? 'medium',
181    shiftedAt: state?.shiftedAt ?? await $.clock.now(),
182    needsReminder: state?.needsReminder ?? false,
183    ...state,
184    ...changes,
185  } satisfies GearState)
186}
187
188export const register: Register = on => {
189  on('turn.step', async function* ($, e, next) {
190    if (!(await enabled($)) || e.agentId !== undefined || !isEffort(e.effort)) {
191      return yield* next(e)
192    }
193    const key = await sessionKey($)
194    const value = await $.store.get(key)
195    const gear = shiftedGear(value)
196    if (gear === undefined) return yield* next(e)
197    const state = gearState(value)
198    const changedModel = state?.modelChanged === true || (state?.effortModel !== undefined && state.effortModel !== e.model)
199    const changedEffort = state?.effortBaseline !== undefined && state.effortBaseline !== e.effort
200    const baseline = changedModel || changedEffort ? e.effort : state?.effortBaseline ?? e.effort
201    // A changed effort is manual only if `/effort` opened its menu since the last shift; one main step consumes the mark.
202    const effortOverride = changedModel
203      ? state?.effortOverride ?? false
204      : state?.effortOverride === true || (changedEffort && state?.effortPicker === true)
205    await $.store.set(key, {
206      gear,
207      shiftedAt: state?.shiftedAt ?? await $.clock.now(),
208      needsReminder: state?.needsReminder ?? false,
209      effortBaseline: baseline,
210      effortModel: e.model,
211      effortOverride,
212      modelChanged: false,
213      effortPicker: false,
214    } satisfies GearState)
215    if (gear === 'medium' || effortOverride) return yield* next(e)
216
217    const effort = gear === 'lazy'
218      ? EFFORTS.indexOf(baseline) > EFFORTS.indexOf('medium') ? 'medium' : baseline
219      : EFFORTS.indexOf(baseline) < EFFORTS.indexOf('xhigh') ? 'xhigh' : baseline
220
221    return yield* next({ ...e, effort })
222  })
223
224  on('session.start', async ($, e, next) => {
225    if (!(await enabled($))) {
226      return next(e)
227    }
228    await $.command.register({ name: 'marcha', description: 'Muestra o cambia la marcha de main', argumentHint: '[lazy|medium|proactive]' })
229    await pruneOldKeys($)
230    // A new process, a resume included, starts with ultracode off; a hot reload keeps what it saw.
231    if ((await $.state.get(ULTRACODE)).value === undefined) await $.state.set(ULTRACODE, false)
232
233    return next(e)
234  })
235
236  on('command.run', { command: 'marcha' }, async ($, e) => {
237    if (!(await enabled($))) {
238      return { text: '' }
239    }
240    const args = e.args.trim().toLowerCase()
241    if (args === '') {
242      return { text: marker(await currentGear($)) }
243    }
244    if (!GEARS.includes(args as Gear)) {
245      return { text: 'Marchas disponibles: lazy|medium|proactive' }
246    }
247    const gear = args as Gear
248    const before = await currentGear($)
249    const key = await sessionKey($)
250    const previous = gearState(await $.store.get(key))
251    await $.store.set(key, {
252      ...previous,
253      gear,
254      shiftedAt: await $.clock.now(),
255      needsReminder: false,
256      effortOverride: false,
257      modelChanged: previous?.modelChanged ?? false,
258      effortPicker: false,
259    } satisfies GearState)
260    if (gear === 'proactive' && before !== 'proactive' && await autonomous($)) {
261      await warn($, costWarning(gear, (await $.state.get(ULTRACODE)).value))
262    }
263
264    return { text: marker(gear), context: [posture(gear)] }
265  })
266
267  on('command.run', { command: 'effort' }, async ($, e, next) => {
268    const effort = e.args.trim().toLowerCase()
269    const active = await enabled($)
270    if (active && e.origin.kind === 'composer' && isEffort(effort)) {
271      const key = await sessionKey($)
272      const value = await $.store.get(key)
273      await saveEffortState($, value, {
274        effortBaseline: effort,
275        effortOverride: true,
276        modelChanged: false,
277      })
278    } else if (active && e.origin.kind === 'composer' && effort !== 'ultracode') {
279      await saveEffortState($, await $.store.get(await sessionKey($)), { effortPicker: true })
280    }
281    const result = await next(e)
282    if (active && effort === 'ultracode') {
283      const { value: before } = await $.state.get(ULTRACODE)
284      await $.state.set(ULTRACODE, true)
285      if (before !== true && await autonomous($)) await warn($, costWarning(await currentGear($), true))
286    }
287
288    return result
289  })
290
291  // The latest ultracode attachment tells its state; a resume asks the history's again, ending on exit (#153).
292  on('prompt.attachment', { type: ['ultra_effort_enter', 'ultra_effort_exit'] }, async ($, e, next) => {
293    if (await enabled($)) await $.state.set(ULTRACODE, e.type === 'ultra_effort_enter')
294
295    return next(e)
296  })
297
298  // modo-autonomo answers /autonomo itself, so the mod watches its write instead of the command.
299  on('state.set', { plugin: 'modo-autonomo', key: 'isOn' }, async ($, e, next) => {
300    const wasOn = await autonomous($)
301    const result = await next(e)
302    const landed = 'value' in result && result.value.isSet
303    if (e.value !== true || wasOn || !landed || !(await enabled($))) return result
304    const gear = await currentGear($)
305    await warn($, gear === 'lazy' ? LAZY_WARNING : undefined)
306    await warn($, costWarning(gear, (await $.state.get(ULTRACODE)).value))
307
308    return result
309  })
310
311  on('command.run', { command: 'model' }, async ($, e, next) => {
312    if (await enabled($) && e.origin.kind === 'composer') {
313      const value = await $.store.get(await sessionKey($))
314      await saveEffortState($, value, { modelChanged: true })
315    }
316
317    return next(e)
318  })
319
320  on('session.compact', async ($, e, next) => {
321    const result = await next(e)
322    if (!(await enabled($)) || ('skip' in result && result.skip !== undefined)) {
323      return result
324    }
325    const key = await sessionKey($)
326    const value = await $.store.get(key)
327    const gear = shiftedGear(value)
328    if (gear === undefined) return result
329    const state = gearState(value)
330    await $.store.set(key, {
331      ...state,
332      gear,
333      shiftedAt: state?.shiftedAt ?? await $.clock.now(),
334      needsReminder: true,
335    } satisfies GearState)
336
337    return result
338  })
339
340  on('classic.SessionStart', async ($, e, next) => {
341    const result = await next(e)
342    if (!(await enabled($)) || e.source !== 'compact') return result
343    const key = await sessionKey($)
344    const value = await $.store.get(key)
345    const gear = shiftedGear(value)
346    if (gear === undefined) return result
347    const state = gearState(value)
348    await $.store.set(key, {
349      ...state,
350      gear,
351      shiftedAt: state?.shiftedAt ?? await $.clock.now(),
352      needsReminder: false,
353    } satisfies GearState)
354
355    return {
356      ...result,
357      additionalContext: [...(result.additionalContext ?? []), posture(gear)],
358    }
359  })
360
361  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
362    if (!(await enabled($))) {
363      return next(e)
364    }
365    const { Text } = $.ui.resolve(e)
366    const gear = await currentGear($)
367    const state = gearState(await $.store.get(await sessionKey($)))
368    const label = gear[0].toUpperCase() + gear.slice(1)
369    const text = state?.needsReminder === true ? `${label} - repet\u00ed /marcha ${gear}` : label
370
371    return <Text color={COLORS[gear]}>{text}</Text>
372  })
373}
374
types/index.d.ts 10 lines
1export type Gear = 'lazy' | 'medium' | 'proactive'
2
3declare module 'claude-code' {
4  interface PluginState {
5    marchas: { ultracode: boolean }
6    // Read only: modo-autonomo owns and writes it (mods/modo-autonomo/types/index.d.ts).
7    'modo-autonomo': { isOn: boolean }
8  }
9}
10