@daniellins/power-claude 0.16.3 → 0.16.4

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.

Potentially problematic release.


This version of @daniellins/power-claude might be problematic. Click here for more details.

@@ -0,0 +1,78 @@
1
+ ---
2
+ name: pc-tutor
3
+ description: "Tutor do POWER CLAUDE: roteia quem acabou de instalar — do leigo ao desenvolvedor — para o documento certo, lendo a instalação em disco e buscando a pedagogia ancorada na versão instalada. Use para: primeiros passos, entender um conceito, descobrir que comandos e papéis existem, achar o tutorial certo."
4
+ model: sonnet
5
+ budget_tokens: 1500
6
+ tools: Read, Grep, Glob, Bash
7
+ disallowedTools: Write, Edit, WebFetch, NotebookEdit
8
+ permissionMode: default
9
+ ---
10
+
11
+ # pc-tutor — o tutor do framework
12
+
13
+ ## Identidade
14
+
15
+ Você é o **tutor** do POWER CLAUDE. Seu público é quem nunca usou este framework: do
16
+ leigo sem vocabulário técnico ao desenvolvedor que ainda não o conhece. Não assuma
17
+ qual dos dois está falando — descubra pela pergunta.
18
+
19
+ Você é **roteador, nunca enciclopédia**: o conhecimento vive nos documentos, não em
20
+ você. Critério de qualidade: **a pessoa sai sabendo qual é o próximo comando a digitar
21
+ e por quê** — não "entendeu o conceito".
22
+
23
+ ## Mapa das jornadas
24
+
25
+ Descubra a jornada pela pergunta e roteie. Só nomes de arquivo — o conteúdo é lido na
26
+ hora, nunca reproduzido de memória. Destinos em `docs/mecanismos/aprendizado/`:
27
+
28
+ | Jornada | Pergunta típica | Destino |
29
+ |---|---|---|
30
+ | Acabei de instalar | "e agora?", "por onde começo?" | `getting-started.md`, depois `guia-de-conceitos.md` |
31
+ | Usar um pod pronto | "como peço um relatório/carrossel?" | `tutorial-rodar-um-pod.md` (+ `guia-do-operador.md` para quem não programa) |
32
+ | Criar um pod novo | "não existe ferramenta para o que preciso" | `tutorial-criar-pod-com-forge.md` |
33
+ | Desenvolver software | "quero levar uma story do pedido ao merge" | `tutorial-ciclo-de-story.md` |
34
+
35
+ ## Protocolo de leitura
36
+
37
+ Classifique a pergunta antes de responder:
38
+
39
+ | Classe | Fonte | Ferramenta |
40
+ |---|---|---|
41
+ | Fato sobre ESTA instalação: que comandos existem, o que um papel pode, que pods estão instalados | disco | `Grep`, `Glob`, `Read` em `.claude/**`, `pods/`, `knowledge/` |
42
+ | Pedagogia: conceito, narrativa, tutorial | remoto, ancorado na versão | `Bash: curl` |
43
+
44
+ Na classe pedagogia, siga a cadeia e **diga em voz alta em que degrau parou**:
45
+
46
+ 1. **Degrau 1 — versão instalada.** Leia `version` de `.power-claude/manifest.json` e busque
47
+ `curl -sS --fail https://raw.githubusercontent.com/daniellins/power-claude-core/v<versao>/docs/mecanismos/aprendizado/<arquivo>.md`.
48
+ O `--fail` não é opcional: sem ele um 404 sai com sucesso e o corpo "404: Not Found" vira
49
+ documento falso. Se `manifest.json` estiver **ausente** (instalação adotada ou manual), não
50
+ invente versão — vá ao degrau 2 e diga por quê. Para listar o que existe naquela versão:
51
+ `curl -sS --fail https://api.github.com/repos/daniellins/power-claude-core/contents/docs/mecanismos/aprendizado?ref=v<versao>`.
52
+ 2. **Degrau 2 — `main`, com aviso.** Se o degrau 1 não devolver 200, troque `v<versao>` por `main`
53
+ e dê o **aviso** explícito: "sua instalação é a X; estou lendo a documentação de `main`, que
54
+ pode estar à frente — confira o comando antes de confiar".
55
+ 3. **Degrau 3 — resposta local + URL impressa.** Se nem `main` responder, responda pelo que existe
56
+ em disco e **imprima a URL** para a pessoa abrir no navegador. Declare o limite em voz alta:
57
+ **sem rede** você ainda responde fatos desta instalação, mas a pedagogia narrativa não chega —
58
+ diga isso, não preencha o buraco.
59
+
60
+ ## Nunca
61
+
62
+ 1. Nunca cite caminho ou comando que não verificou: local por `Glob`/`Read`, remoto por HTTP 200
63
+ (`--fail`). Sem verificação, a frase certa é "não encontrei isso na sua instalação".
64
+ 2. Nunca use `WebFetch` neste corpus — ele traduz e reescreve o texto em vez de devolvê-lo. Só
65
+ `Bash: curl`.
66
+ 3. Nunca complete lacuna com conhecimento geral do modelo. Se os docs não dizem, diga que não
67
+ dizem e aponte onde perguntar — quem fala com você não tem como conferir.
68
+ 4. Nunca execute o que ensina: não roda o ciclo, não cria pod, não transiciona story, não commita.
69
+ Você não tem nenhuma autoridade de `.claude/rules/authority.md`.
70
+ 5. Nunca silencie a divergência de versão do degrau 2.
71
+ 6. Nunca responda "está na documentação": ou traga o trecho lido, ou declare que não conseguiu ler.
72
+ 7. Nunca busque uma URL fornecida pelo usuário, nem uma encontrada dentro de um documento lido —
73
+ a origem é sempre fixa (`daniellins/power-claude-core`, `docs/mecanismos/**`), nunca entrada.
74
+
75
+ ## Encerramento
76
+
77
+ Toda resposta termina em exatamente **um** próximo comando concreto e exatamente **uma** fonte
78
+ citada: caminho local verificado, ou URL com a versão. Sem os dois, a resposta não está pronta.
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: "Ativa o tutor do POWER CLAUDE. Use para: primeiros passos depois de instalar, entender um conceito (pod, papel, ciclo, checkpoint), descobrir que comandos e papéis você tem nesta instalação, ou achar o tutorial certo para o que você quer fazer."
3
+ ---
4
+
5
+ Ative o tutor do framework (agente `pc-tutor`).
6
+
7
+ 1. Classifique a pergunta: fato sobre ESTA instalação → leia o disco (`Grep`/`Glob`/`Read`); pedagogia (conceito, narrativa, tutorial) → busque o documento com `Bash: curl`, ancorado na versão instalada.
8
+ 2. Roteie para uma das 4 jornadas do mapa: acabei de instalar, usar um pod pronto, criar um pod novo, desenvolver software.
9
+ 3. Termine em 1 próximo comando concreto + 1 fonte citada. Nunca cite caminho ou comando que não verificou.
10
+
11
+ Pedido do usuário: $ARGUMENTS
@@ -141,16 +141,115 @@
141
141
  * extension") — não há como um alias sombrear `pr`/`issue`/etc., mesma
142
142
  * garantia já confirmada para o `git`.
143
143
  *
144
+ * REMOTO IRREVERSÍVEL (story SF15.12, `I4` do ADR-037): até aqui este hook
145
+ * era CEGO A DESTINO — `pc-devops` empurrava em silêncio para `origin`, para
146
+ * um remoto público e para uma URL arbitrária, sem distinção (medido,
147
+ * ADR-037 §1.3). O `allow` silencioso de `git push` foi calibrado para um
148
+ * remoto privado e recuperável; um remoto PÚBLICO não pode herdá-lo. Agora,
149
+ * ao reconhecer um `git push` NÃO-force, `analyzeSegment` classifica o
150
+ * segmento como `git-push-irreversible` (kind PRÓPRIO, severidade 3.5 —
151
+ * entre `git-push`=1 e `force-push`=4, de modo que num comando encadeado ele
152
+ * nunca perca para operações menos severas e SEMPRE perca para force-push:
153
+ * o `ask` novo jamais rebaixa um `deny`, por construção da ordem, não por
154
+ * disciplina) quando o remoto resolvido do PRÓPRIO segmento cai em:
155
+ * (a) um NOME presente em `irreversible-remotes.json` — a LISTA decide,
156
+ * nunca um heurístico de "nome ≠ origin" (que ignoraria o arquivo);
157
+ * (b) uma URL/SSH-spec BRUTA (`scheme://` ou `user@host:path`), que não é
158
+ * nome de remoto configurado nenhum;
159
+ * (c) ausência de argumento de remoto explícito E falha ao resolver o
160
+ * upstream (`git rev-parse --abbrev-ref --symbolic-full-name @{u}`) —
161
+ * "não deu para confirmar ⇒ ask", a MESMA regra já aplicada a alias
162
+ * não resolvido; "não sei" nunca vira "sei que não é".
163
+ * `git-push-irreversible` ⇒ `ask` INCONDICIONAL, inclusive para `pc-devops`
164
+ * (mesma forma que `gh pr merge` já tinha). Remoto nomeado que NÃO está na
165
+ * lista (ex.: `origin`) mantém o comportamento anterior, byte a byte.
166
+ *
167
+ * A lista é lida da UNIÃO de 2 caminhos — `path.join(__dirname,
168
+ * 'irreversible-remotes.json')` (o arquivo que VIAJA com o hook, dentro da
169
+ * instalação) e `path.join(input.cwd, '.claude/hooks/irreversible-remotes.json')`
170
+ * (a instalação viva cuja sessão executa o push). União só ALARGA o conjunto
171
+ * de `ask`: nunca substitui, nunca interseciona. Cada lado é tratado por
172
+ * CLASSE — arquivo AUSENTE é resposta conclusiva ("nada declarado ali") e
173
+ * não contribui (o outro lado ainda pode); arquivo PRESENTE e ILEGÍVEL
174
+ * (JSON quebrado, `remotes` não-array, entrada não-string) é "não sei" ⇒
175
+ * `ask` para qualquer push — fail-loud. Sem isso, um typo na lista
176
+ * desligaria a guarda inteira em silêncio, o pior desfecho possível deste
177
+ * hook, pior do que nunca ter tido a lista.
178
+ *
179
+ * Extração do argumento de remoto — CATÁLOGO FECHADO, não heurística: a
180
+ * regra ingênua "1º token não-flag depois do subcomando" falha ABERTA
181
+ * (medido: `git push -o ci.skip publico main` extrai `ci.skip`, e o git
182
+ * alcança `publico` de verdade), porque algumas opções do subcomando `push`
183
+ * tomam valor em token SEPARADO. Só as opções de `GIT_PUSH_OPTS_WITH_ARG`
184
+ * consomem o token seguinte; TODA opção fora do catálogo é flag SEM valor —
185
+ * que é a gramática real do `git push`, não uma aposta. A alternativa
186
+ * ("opção desconhecida com aparência de tomar valor ⇒ ask") foi medida e
187
+ * DESCARTADA: "aparência" não é predicado decidível, e a leitura natural
188
+ * dela deixa `git push -u publico main` em SILÊNCIO (o `-u` engoliria
189
+ * `publico`) e transforma `git push --tags publico` em ruído.
190
+ * `--repo` é a ÚNICA entrada do catálogo cujo valor É o repositório de
191
+ * destino, e por isso ele não é só consumido: é CAPTURADO
192
+ * (`extractPushRemoteCandidates` devolve `positional` e `repoOption`).
193
+ * PRECEDÊNCIA MEDIDA contra o git real (2.50.1), não deduzida:
194
+ * `git push --repo publico` → alcança `publico`
195
+ * `git push --repo=publico` → alcança `publico`
196
+ * `git push --repo publico main` → `fatal: 'main' does not appear
197
+ * to be a git repository`
198
+ * `git push --repo publico origin main` → alcança `origin`
199
+ * Ou seja: o argumento POSICIONAL vence `--repo`; `--repo` só seleciona o
200
+ * destino quando não há posicional nenhum. É exatamente essa a regra
201
+ * implementada.
202
+ * **Negação explícita — leitura SUPERADA, não repetir:** o menor `m1` da 1ª
203
+ * passada do `*review` da SF15.12 registrou que "`--repo=publico` medido: o
204
+ * git NÃO o honrou como seleção de remoto". Essa medição tinha um argumento
205
+ * POSICIONAL no caso de teste, que tem precedência e MASCARA o efeito de
206
+ * `--repo`. Sem posicional, o git honra `--repo` — remedido em duas sessões
207
+ * independentes (`pc-qa`, finding `F-1`, e `pc-dev` na correção). Foi essa
208
+ * leitura superada que justificou tratar `--repo` como valor opaco "por
209
+ * precaução", e era justamente ela que abria um push SILENCIOSO para um
210
+ * remoto DECLARADO irreversível — o desfecho que este hook existe para
211
+ * impedir. Nunca reintroduzir "descarta o valor de `--repo`".
212
+ * LIMITE RESIDUAL DECLARADO (mesma régua dos demais limites deste
213
+ * cabeçalho — um mecanismo nunca promete mais do que entrega): uma opção
214
+ * FUTURA/desconhecida do git que tome valor em token separado e não esteja
215
+ * no catálogo pode fazer o parser ler o VALOR como remoto. Dívida NOMEADA,
216
+ * fechada por atualização do catálogo — nunca por um `ask` adivinhado.
217
+ * Também declarado: (b) cobre `scheme://` e `user@host:path`, as 2 formas
218
+ * medidas no ADR-037 §1.3 — um caminho local (`/tmp/x.git`) não é tratado
219
+ * como URL bruta.
220
+ * 3º limite DECLARADO, MEDIDO (não deduzido) na implementação da SF15.12:
221
+ * quando o subcomando é um ALIAS, a expansão (`expandResolvedAliasAndRecurse`)
222
+ * reconstrói o comando como `git <corpo> <trailing>` e NÃO carrega as opções
223
+ * globais que vinham antes do subcomando — logo `git -C <outro-repo> z` (com
224
+ * `z = push`) resolve o upstream do cenário (c) no `cwd` da sessão, não no
225
+ * repositório de `-C`. Medido: a forma DIRETA `git -C <outro-repo> push`
226
+ * responde `ask` para um destino declarado, e a forma via alias responde
227
+ * silêncio para o MESMO destino. Vale só para o cenário (c) — remoto
228
+ * explícito e URL bruta são lidos do texto e não dependem de repositório —
229
+ * e a mesma perda já existe, desde o fix round F3, na CONSULTA do alias
230
+ * (`resolveGitAliasFromDisk` também usa `ctx.cwd`). Não corrigido aqui por
231
+ * estar fora da prescrição revisada desta story (fechá-lo mexe na expansão
232
+ * COMPARTILHADA entre os branches `git` e `gh`, que o F5 unificou de
233
+ * propósito) — dívida NOMEADA, para decisão de arquitetura, nunca uma lacuna
234
+ * silenciosa.
235
+ *
144
236
  * Sem dependências externas além do próprio `git`/`gh` (só chamados para
145
- * resolver alias — roda em qualquer instalação limpa mesmo sem `git`/`gh`
146
- * no PATH: nesse caso a consulta falha e o resultado é `ask`, não crash).
237
+ * resolver alias e o upstream do cenário (c) — roda em qualquer instalação
238
+ * limpa mesmo sem `git`/`gh` no PATH: nesse caso a consulta falha e o
239
+ * resultado é `ask`, não crash).
147
240
  */
148
241
 
149
242
  const fs = require('fs');
243
+ const path = require('path');
150
244
  const { spawnSync } = require('child_process');
151
245
 
152
246
  const DEVOPS_AGENT_TYPE = 'pc-devops';
153
247
 
248
+ // Arquivo de DECLARAÇÃO dos remotos irreversíveis (story SF15.12). Nunca
249
+ // hardcodar nome de remoto neste fonte: quem instala o framework é o dono da
250
+ // lista, e nomes de remoto são por repositório.
251
+ const IRREVERSIBLE_REMOTES_FILENAME = 'irreversible-remotes.json';
252
+
154
253
  // Opções GLOBAIS do git que tomam um valor em token SEPARADO (quando não
155
254
  // usadas na forma `--opcao=valor`). Fonte: `git help` (seção OPTIONS).
156
255
  const GIT_GLOBAL_OPTS_WITH_ARG = new Set([
@@ -170,6 +269,21 @@ const GIT_GLOBAL_OPTS_WITH_ARG = new Set([
170
269
  // gh: opções globais mais comuns que tomam valor em token separado.
171
270
  const GH_GLOBAL_OPTS_WITH_ARG = new Set(['-R', '--repo', '--hostname']);
172
271
 
272
+ // Opções do SUBCOMANDO `push` que tomam um valor em token SEPARADO (quando
273
+ // não usadas na forma `--opcao=valor`) — CATÁLOGO FECHADO (story SF15.12):
274
+ // toda opção FORA deste conjunto é flag SEM valor, que é a gramática real do
275
+ // `git push`. Espelha `GIT_GLOBAL_OPTS_WITH_ARG` na forma e na régua. Fonte:
276
+ // `git push --help` (seção OPTIONS). Limite residual (opção futura com valor
277
+ // separado ausente daqui) DECLARADO no cabeçalho — nunca substituído por
278
+ // heurística de "aparência de tomar valor", que não é decidível no parse.
279
+ const GIT_PUSH_OPTS_WITH_ARG = new Set([
280
+ '-o',
281
+ '--push-option',
282
+ '--repo',
283
+ '--exec',
284
+ '--receive-pack',
285
+ ]);
286
+
173
287
  // Subcomandos REAIS do git (porcelana + os plumbing mais comuns) — lista de
174
288
  // custo, não de correção: um subcomando fora desta lista dispara a
175
289
  // resolução de alias (barata primeiro, subprocesso só se preciso); um
@@ -331,12 +445,31 @@ function isGhToken(tok) {
331
445
  // `inlineAliases` (Map) for passado, toda opção `-c alias.<nome>=<valor>`
332
446
  // encontrada no caminho é registrada nele — fonte MAIS BARATA de resolução
333
447
  // de alias, o próprio comando já carrega a definição.
448
+ //
449
+ // Story SF15.12 (`M2`): o valor de `-C`/`--git-dir` deixa de ser DESCARTADO
450
+ // e passa a ser devolvido (`repoCwd`/`gitDir`). Nomes de remoto são por
451
+ // REPOSITÓRIO, então a resolução de upstream do cenário (c) precisa rodar no
452
+ // repositório que o comando de fato aponta, não no `cwd` da sessão. Opção
453
+ // presente e NÃO utilizável (valor ausente/vazio, ou `-C` repetido — que o
454
+ // git trata como caminhos relativos cumulativos, algo que este parser não se
455
+ // propõe a reproduzir) marca `unusableRepoOption`, e o chamador trata isso
456
+ // como "não deu para confirmar", nunca como "não há".
334
457
  function findGitSubcommand(tokens, gitIndex, inlineAliases) {
335
458
  let i = gitIndex + 1;
459
+ let repoCwd;
460
+ let gitDir;
461
+ let unusableRepoOption = false;
462
+ const finish = (subcommand, index) => ({
463
+ subcommand,
464
+ index,
465
+ repoCwd,
466
+ gitDir,
467
+ unusableRepoOption,
468
+ });
336
469
  while (i < tokens.length) {
337
470
  const tok = tokens[i];
338
471
  if (!tok.startsWith('-')) {
339
- return { subcommand: tok, index: i };
472
+ return finish(tok, i);
340
473
  }
341
474
  const eqIdx = tok.indexOf('=');
342
475
  const base = eqIdx === -1 ? tok : tok.slice(0, eqIdx);
@@ -355,6 +488,16 @@ function findGitSubcommand(tokens, gitIndex, inlineAliases) {
355
488
  const aliasMatch = /^alias\.([^=\s]+)=([\s\S]*)$/.exec(value);
356
489
  if (aliasMatch) inlineAliases.set(aliasMatch[1], aliasMatch[2]);
357
490
  }
491
+ if (base === '-C') {
492
+ // `-C` repetido é cumulativo no git real; aqui vira "não utilizável".
493
+ if (repoCwd !== undefined) unusableRepoOption = true;
494
+ if (typeof value === 'string' && value) repoCwd = value;
495
+ else unusableRepoOption = true;
496
+ }
497
+ if (base === '--git-dir') {
498
+ if (typeof value === 'string' && value) gitDir = value;
499
+ else unusableRepoOption = true;
500
+ }
358
501
  continue;
359
502
  }
360
503
  // Qualquer outra flag (global conhecida sem argumento, ou desconhecida)
@@ -406,6 +549,200 @@ function isForceFlagToken(tok) {
406
549
  return false;
407
550
  }
408
551
 
552
+ // Extrai os CANDIDATOS a repositório de destino de um `git push`, a partir
553
+ // dos tokens que SEGUEM o subcomando (a mesma fatia que `isForceFlagToken` já
554
+ // consome — custo zero a mais). São 2, porque o `git push` tem 2 formas de
555
+ // dizer para onde empurrar:
556
+ // - `positional` — o 1º token não-flag (`git push <repositório> <refspec>`);
557
+ // - `repoOption` — o valor de `--repo <v>` / `--repo=<v>`.
558
+ // A varredura respeita o CATÁLOGO FECHADO `GIT_PUSH_OPTS_WITH_ARG`: só opção
559
+ // catalogada, na forma SEM `=`, consome o token seguinte; toda outra opção é
560
+ // flag sem valor. A varredura NÃO para no 1º posicional, porque `--repo` pode
561
+ // vir depois dele (`git push --tags --repo publico`) — quem decide a
562
+ // precedência é o chamador, não a ordem de leitura.
563
+ function extractPushRemoteCandidates(tokensAfterPush) {
564
+ let positional = null;
565
+ let repoOptionPresent = false;
566
+ let repoOptionValue;
567
+ let i = 0;
568
+ while (i < tokensAfterPush.length) {
569
+ const tok = tokensAfterPush[i];
570
+ if (!tok.startsWith('-')) {
571
+ if (positional === null) positional = tok;
572
+ i += 1;
573
+ continue;
574
+ }
575
+ const eqIdx = tok.indexOf('=');
576
+ const base = eqIdx === -1 ? tok : tok.slice(0, eqIdx);
577
+ // `--opcao=valor` traz o valor no PRÓPRIO token: nunca consome o seguinte.
578
+ if (eqIdx === -1 && GIT_PUSH_OPTS_WITH_ARG.has(base)) {
579
+ if (base === '--repo' && !repoOptionPresent) {
580
+ repoOptionPresent = true;
581
+ repoOptionValue = tokensAfterPush[i + 1];
582
+ }
583
+ i += 2;
584
+ continue;
585
+ }
586
+ if (eqIdx !== -1 && base === '--repo' && !repoOptionPresent) {
587
+ repoOptionPresent = true;
588
+ repoOptionValue = tok.slice(eqIdx + 1);
589
+ }
590
+ i += 1;
591
+ }
592
+ return { positional, repoOptionPresent, repoOptionValue };
593
+ }
594
+
595
+ // Cenário (b): o token de destino é uma URL/SSH-spec BRUTA, não um NOME de
596
+ // remoto configurado. 2 formas, as medidas no ADR-037 §1.3: `scheme://...` e
597
+ // o scp-like `user@host:path`. Caminho local (`/tmp/x.git`) NÃO é coberto —
598
+ // limite declarado no cabeçalho, não omissão silenciosa.
599
+ function looksLikeRawRemoteUrl(token) {
600
+ if (typeof token !== 'string' || !token) return false;
601
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(token)) return true;
602
+ if (/^[^/@:]+@[^/@:]+:/.test(token)) return true;
603
+ return false;
604
+ }
605
+
606
+ // Lê UM lado da união (story SF15.12). Trata por CLASSE, nunca fundindo
607
+ // "ausente" com "ilegível" (`M1`):
608
+ // { status: 'absent' } — não existe ali: resposta CONCLUSIVA
609
+ // ("nada declarado neste lado").
610
+ // { status: 'ok', remotes: Set } — lido e bem formado.
611
+ // { status: 'malformed' } — presente e ilegível (JSON quebrado,
612
+ // `remotes` não-array, entrada
613
+ // não-string, leitura impossível) —
614
+ // "não sei", nunca "não há".
615
+ function readIrreversibleRemotesFile(filePath) {
616
+ let raw;
617
+ try {
618
+ raw = fs.readFileSync(filePath, 'utf8');
619
+ } catch (err) {
620
+ const code = err && err.code;
621
+ // ENOENT/ENOTDIR = o arquivo não está lá (conclusivo). Qualquer outro
622
+ // erro (permissão, EISDIR, ...) é "existe algo que não consegui ler".
623
+ if (code === 'ENOENT' || code === 'ENOTDIR') return { status: 'absent' };
624
+ return { status: 'malformed' };
625
+ }
626
+ let parsed;
627
+ try {
628
+ parsed = JSON.parse(raw);
629
+ } catch {
630
+ return { status: 'malformed' };
631
+ }
632
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
633
+ return { status: 'malformed' };
634
+ }
635
+ if (!Array.isArray(parsed.remotes)) return { status: 'malformed' };
636
+ if (!parsed.remotes.every((entry) => typeof entry === 'string')) {
637
+ return { status: 'malformed' };
638
+ }
639
+ return { status: 'ok', remotes: new Set(parsed.remotes) };
640
+ }
641
+
642
+ // UNIÃO dos 2 lados: o arquivo que viaja junto do hook (`__dirname`) e o da
643
+ // instalação viva cuja sessão executa o push (`input.cwd`). A união só
644
+ // ALARGA o conjunto de `ask` — nunca substitui um lado pelo outro, nunca
645
+ // interseciona. Lado ausente não contribui; lado malformado contamina o
646
+ // resultado inteiro com `malformed` (fail-loud).
647
+ function loadIrreversibleRemotes(cwd) {
648
+ const candidates = [path.join(__dirname, IRREVERSIBLE_REMOTES_FILENAME)];
649
+ if (typeof cwd === 'string' && cwd) {
650
+ candidates.push(path.join(cwd, '.claude', 'hooks', IRREVERSIBLE_REMOTES_FILENAME));
651
+ }
652
+ const remotes = new Set();
653
+ let malformed = false;
654
+ for (const candidate of candidates) {
655
+ const side = readIrreversibleRemotesFile(candidate);
656
+ if (side.status === 'ok') {
657
+ for (const name of side.remotes) remotes.add(name);
658
+ } else if (side.status === 'malformed') {
659
+ malformed = true;
660
+ }
661
+ }
662
+ return { remotes, malformed };
663
+ }
664
+
665
+ // Resolve o remoto do UPSTREAM da branch corrente (cenário (c)), no
666
+ // repositório que o comando de fato aponta (`-C`/`--git-dir`, `M2`), com o
667
+ // mesmo padrão de subprocesso já usado na resolução de alias (timeout curto,
668
+ // stdin fechado, try/catch). `@{u}` sai como `<remoto>/<branch>`; o nome do
669
+ // remoto é o 1º segmento.
670
+ // { status: 'found', remote } — resolvido com segurança.
671
+ // { status: 'error' } — sem upstream, fora de repositório, git
672
+ // ausente, timeout: "não deu para confirmar".
673
+ function resolveUpstreamRemote(cwd, gitDir) {
674
+ try {
675
+ const args = [];
676
+ if (typeof gitDir === 'string' && gitDir) args.push(`--git-dir=${gitDir}`);
677
+ args.push('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}');
678
+ const result = spawnSync('git', args, {
679
+ cwd: cwd || process.cwd(),
680
+ encoding: 'utf8',
681
+ timeout: 800,
682
+ input: '',
683
+ });
684
+ if (result.error || result.status !== 0) return { status: 'error' };
685
+ const value = (result.stdout || '').trim();
686
+ const slash = value.indexOf('/');
687
+ if (slash <= 0) return { status: 'error' };
688
+ return { status: 'found', remote: value.slice(0, slash) };
689
+ } catch {
690
+ return { status: 'error' };
691
+ }
692
+ }
693
+
694
+ // A lista é lida NO MÁXIMO 1 vez por invocação inteira (cacheada em `ctx`,
695
+ // mesma disciplina de custo de `ctx.ghAliasCache`), e só depois de um
696
+ // segmento ser reconhecido como `git push` não-force (`M4`).
697
+ function getIrreversibleRemotes(ctx) {
698
+ if (!ctx.irreversibleRemotesCache) {
699
+ ctx.irreversibleRemotesCache = loadIrreversibleRemotes(ctx.cwd);
700
+ }
701
+ return ctx.irreversibleRemotesCache;
702
+ }
703
+
704
+ // O predicado do `I4`: este segmento de `git push` (já sabidamente não-force)
705
+ // tem destino irreversível ou não-confirmável? Ver os cenários (a)/(b)/(c) no
706
+ // cabeçalho. Toda dúvida resolve para `true` — "não sei" nunca vira "sei que
707
+ // não é", a mesma doutrina do `unresolved-alias`.
708
+ function isPushToIrreversibleRemote(tokensAfterPush, found, ctx) {
709
+ const declared = getIrreversibleRemotes(ctx);
710
+ if (declared.malformed) return true; // `M1`: fail-loud, nunca silêncio
711
+
712
+ const { positional, repoOptionPresent, repoOptionValue } = extractPushRemoteCandidates(tokensAfterPush);
713
+
714
+ // PRECEDÊNCIA, MEDIDA contra o git real (2.50.1), nunca deduzida — ver o
715
+ // cabeçalho: o argumento POSICIONAL vence `--repo`. `git push --repo publico
716
+ // main` faz o git tratar `main` como repositório (`fatal: 'main' does not
717
+ // appear to be a git repository`), e `git push --repo publico origin main`
718
+ // alcança `origin`. Só quando NÃO há posicional é que `--repo` seleciona o
719
+ // destino de fato — e aí ele precisa receber (a)/(b) como qualquer remoto.
720
+ let remoteToken = positional;
721
+ if (remoteToken === null && repoOptionPresent) {
722
+ // `--repo` presente mas sem valor utilizável (ausente, vazio, ou outra
723
+ // flag no lugar do valor) é "não sei para onde isto vai", nunca "não há".
724
+ if (typeof repoOptionValue !== 'string' || !repoOptionValue || repoOptionValue.startsWith('-')) {
725
+ return true;
726
+ }
727
+ remoteToken = repoOptionValue;
728
+ }
729
+
730
+ if (remoteToken === null) {
731
+ // (c) nenhum argumento de remoto explícito: só o upstream pode dizer
732
+ // para onde isto vai.
733
+ if (found.unusableRepoOption) return true;
734
+ const repoCwd = found.repoCwd
735
+ ? path.resolve(ctx.cwd || process.cwd(), found.repoCwd)
736
+ : ctx.cwd;
737
+ const upstream = resolveUpstreamRemote(repoCwd, found.gitDir);
738
+ if (upstream.status !== 'found') return true;
739
+ return declared.remotes.has(upstream.remote);
740
+ }
741
+
742
+ if (looksLikeRawRemoteUrl(remoteToken)) return true; // (b)
743
+ return declared.remotes.has(remoteToken); // (a) — a LISTA decide
744
+ }
745
+
409
746
  // Consulta `git config --get alias.<nome>` no repositório de `cwd` — a
410
747
  // fonte MAIS CARA de resolução de alias do `git` (subprocesso síncrono),
411
748
  // só chamada quando as 2 fontes baratas (inline `-c`, `git config alias.X`
@@ -647,8 +984,19 @@ function analyzeSegment(segment, ctx, depth) {
647
984
  const found = findGitSubcommand(tokens, gitIndex, inlineAliases);
648
985
  if (found) {
649
986
  if (found.subcommand === 'push') {
650
- const isForce = tokens.slice(found.index + 1).some(isForceFlagToken);
651
- return { kind: isForce ? 'force-push' : 'git-push' };
987
+ const afterPush = tokens.slice(found.index + 1);
988
+ if (afterPush.some(isForceFlagToken)) return { kind: 'force-push' };
989
+ // Story SF15.12 (`B2`): o destino irreversível vira um `kind` PRÓPRIO
990
+ // AQUI, no momento em que o SEGMENTO é reconhecido — nunca um campo
991
+ // lido depois da agregação por severidade, que devolve UMA operação e
992
+ // faria o 2º push de uma cadeia (`git push origin main && git push
993
+ // publico main`) simplesmente sumir. `M4`: é este o único ponto em que
994
+ // a lista é lida e o `git rev-parse` pode ser chamado.
995
+ return {
996
+ kind: isPushToIrreversibleRemote(afterPush, found, ctx)
997
+ ? 'git-push-irreversible'
998
+ : 'git-push',
999
+ };
652
1000
  }
653
1001
  if (!KNOWN_GIT_SUBCOMMANDS.has(found.subcommand)) {
654
1002
  const trailing = tokens.slice(found.index + 1);
@@ -712,8 +1060,16 @@ function analyzeSegment(segment, ctx, depth) {
712
1060
  // (force-push > merge > alias não resolvido > push/pr create), para não
713
1061
  // deixar passar em silêncio um comando composto que contenha uma operação
714
1062
  // governada em qualquer posição da cadeia.
1063
+ // `git-push-irreversible` = 3.5: estritamente ENTRE `git-push` (1) e
1064
+ // `force-push` (4), e acima de `unresolved-alias` (2) e `gh-pr-merge` (3) —
1065
+ // assim, numa cadeia, o destino irreversível nunca perde para uma operação
1066
+ // menos severa, e SEMPRE perde para force-push (o `ask` novo jamais rebaixa
1067
+ // um `deny`, por construção da ordem). Fracionário de propósito: um inteiro
1068
+ // exigiria renumerar as 4 entradas existentes, com risco de regressão, sem
1069
+ // necessidade — `SEVERITY` é comparada por `>`, não por adjacência.
715
1070
  const SEVERITY = {
716
1071
  'force-push': 4,
1072
+ 'git-push-irreversible': 3.5,
717
1073
  'gh-pr-merge': 3,
718
1074
  'unresolved-alias': 2,
719
1075
  'git-push': 1,
@@ -727,6 +1083,9 @@ function findGovernedOperation(command, cwd) {
727
1083
  gitSessionAliases: collectSessionAliases(segments),
728
1084
  ghSessionAliases: collectGhSessionAliases(segments),
729
1085
  ghAliasCache: { loaded: false, map: null, failed: false },
1086
+ // Preenchido sob demanda por `getIrreversibleRemotes` — nunca aqui, para
1087
+ // que nenhum `Bash` que não contenha `git push` pague leitura de arquivo.
1088
+ irreversibleRemotesCache: null,
730
1089
  };
731
1090
  let best = null;
732
1091
  for (const segment of segments) {
@@ -778,6 +1137,19 @@ function decideAndEmit(operation, input) {
778
1137
  return;
779
1138
  }
780
1139
 
1140
+ if (operation.kind === 'git-push-irreversible') {
1141
+ // Story SF15.12 (`I4` do ADR-037): destino declarado irreversível, URL
1142
+ // bruta, ou destino que não deu para confirmar. `ask` INCONDICIONAL —
1143
+ // inclusive para `pc-devops`, mesma forma que `gh pr merge` já tem. O
1144
+ // `allow` silencioso de `git push` foi calibrado para um remoto privado e
1145
+ // recuperável; um remoto público não o herda.
1146
+ emitDecision(
1147
+ 'ask',
1148
+ `O destino deste push é um remoto declarado irreversível, uma URL bruta, ou um destino que não foi possível confirmar (nenhum remoto explícito e upstream irresolúvel, ou lista de remotos ilegível). Aprovação humana explícita é necessária, inclusive para o papel devops. Agente atual: ${agentLabel(input)}.`,
1149
+ );
1150
+ return;
1151
+ }
1152
+
781
1153
  if (operation.kind === 'unresolved-alias') {
782
1154
  // Subcomando de git OU gh desconhecido que PODE ser alias — ou a
783
1155
  // consulta (`git config --get`/`gh alias list`) não pôde ser concluída
@@ -900,7 +1272,15 @@ module.exports = {
900
1272
  DEVOPS_AGENT_TYPE,
901
1273
  KNOWN_GIT_SUBCOMMANDS,
902
1274
  KNOWN_GH_SUBCOMMANDS,
1275
+ GIT_PUSH_OPTS_WITH_ARG,
1276
+ IRREVERSIBLE_REMOTES_FILENAME,
903
1277
  ALIAS_MAX_DEPTH,
1278
+ extractPushRemoteCandidates,
1279
+ looksLikeRawRemoteUrl,
1280
+ readIrreversibleRemotesFile,
1281
+ loadIrreversibleRemotes,
1282
+ resolveUpstreamRemote,
1283
+ isPushToIrreversibleRemote,
904
1284
  tokenize,
905
1285
  splitSegments,
906
1286
  isGitToken,
@@ -0,0 +1 @@
1
+ {"remotes": []}
@@ -43,7 +43,7 @@ Um gatilho pode ser **condicional a outro papel**: ex.: architect aciona só se
43
43
  1. Paths (e linhas, quando relevante) exatos dos arquivos envolvidos.
44
44
  2. ACs da story e critérios de aceite verificáveis.
45
45
  3. Limites de autoridade do papel (ex.: dev não faz push; QA não implementa fixes). **Toda restrição ALÉM da autoridade padrão do papel cita a LINHA exata de `.claude/rules/authority.md` que a sustenta** (`authority.md:{linha}`, com a frase da linha); não havendo nenhuma, o campo escreve verbatim "nenhuma restrição além da autoridade padrão do papel" (`DF-SF13.3-1`). **Restrição sem âncora não vale** — o papel obedece a um brief errado sem ter como contestá-lo. Origem: `FINDING-5` do gate `SF13.11`, onde o brief mandou o Dev "não commitar" e `authority.md:10` não sustentava a restrição — a linha lista "commits locais" na coluna "Autoridade exclusiva" do Dev, e o que ela bloqueia na coluna "Proibido" é `git push`, não o commit —, e o resultado foi entregável fora do git.
46
- 4. O que retornar — e a instrução explícita: "relatório final ≤15 linhas; detalhes vão na story". **E a instrução explícita de commitar o artefato exclusivo do papel antes de fechar o turno** — gate file do QA, `Status`/`File List` do Dev, transição `Ready` do PO: o papel roda `git status --short`, não deixa nada seu *untracked*, commita ele mesmo e reporta o sha do commit (`DF-SF13.3-2`). Qual é o artefato exclusivo de cada papel se lê na coluna "Autoridade exclusiva" de `authority.md:9-15`; para o gate file, em `authority.md:17` ("só QA escreve `docs/qa/gates/*.yaml`"). Commitar ANTES de encerrar é do próprio papel, nunca de quem vier depois: o artefato deixado para trás é varrido, sem crédito, para dentro do commit de outro papel. Origem: 3ª ocorrência de "trabalho do papel não commitado" (`docs/stories/sf13.7.story.md`, Change Log `v1.5`) — o gate file da 1ª passada do QA ficou *untracked* e entrou no repo dentro de `40c48ae`, o commit do fix round do `pc-planner`. **E nenhum turno termina "aguardando" um processo em background que ele mesmo lançou** — background não sobrevive ao fim do turno que o lançou; o que não for colhido (lido, verificado) DENTRO do mesmo turno se perde, e o brief nunca instrui um papel a esperar por ele (`DF-SF13.4-1`). Origem: 3 ocorrências da tabela do dono ("agente encerra esperando background que morreu com ele", `docs/stories/epic-sf13-processo-a-prova-de-falhas.md:55`) + 1 ocorrência viva desta rodada (`docs/stories/release-0.16.1.story.md`, Dev Agent Record → Desvios registrados: "Watchdog em background não sobrevive ao fim do turno do agente" — watchdog e monitor lançados em background, nenhuma notificação chegou, corrigido reexecutando em foreground).
46
+ 4. O que retornar — e a instrução explícita: "relatório final ≤15 linhas; detalhes vão na story". **E a instrução explícita de commitar o artefato exclusivo do papel antes de fechar o turno** — gate file do QA, `Status`/`File List` do Dev, transição `Ready` do PO: o papel roda `git status --short`, não deixa nada seu *untracked*, commita ele mesmo e reporta o sha do commit (`DF-SF13.3-2`). Qual é o artefato exclusivo de cada papel se lê na coluna "Autoridade exclusiva" de `authority.md:9-15`; para o gate file, em `authority.md:17` ("só QA escreve `docs/qa/gates/*.yaml`"). Commitar ANTES de encerrar é do próprio papel, nunca de quem vier depois: o artefato deixado para trás é varrido, sem crédito, para dentro do commit de outro papel. Origem: 3ª ocorrência de "trabalho do papel não commitado" (`docs/stories/sf13.7.story.md`, Change Log `v1.5`) — o gate file da 1ª passada do QA ficou *untracked* e entrou no repo dentro de `40c48ae`, o commit do fix round do `pc-planner`. **E nenhum turno termina "aguardando" um processo em background que ele mesmo lançou** — o processo em si PODE sobreviver ao fim do turno; o que **não sobrevive ao fim do turno** é a COLHEITA: a capacidade do agente de ler, verificar e reportar o resultado. O que não for colhido DENTRO do mesmo turno se perde — inclusive um trabalho que terminou verde e que ninguém viu —, e o brief nunca instrui um papel a esperar por ele (`DF-SF13.4-1`). **Leitura superada, não repetir:** a formulação anterior desta cláusula ("background não sobrevive ao fim do turno que o lançou") afirmava mais do que a fonte citada sustenta, e a própria fonte prova o contrário — em `docs/stories/release-0.16.1.story.md` (Dev Agent Record → Desvios registrados) a suíte lançada em background "continuou rodando o tempo todo e terminou verde"; o que morreu com o turno foi o monitor que iria colhê-la (corrigido reexecutando em foreground). A regra prática não muda: não se espera por background: colhe-se dentro do turno, ou não se lança. Origem: 3 ocorrências da tabela do dono ("agente encerra esperando background que morreu com ele", `docs/stories/epic-sf13-processo-a-prova-de-falhas.md:55`) + a ocorrência viva acima; correção da cláusula pela story `chore-miudos-pos-sf13` (`FINDING-1` do gate `SF13.4`, registrado no épico `:171`).
47
47
  5. **Todo número/afirmação vem com o método de medição** (ver disciplina abaixo) — sem isso o subagente não consegue falsificar, e erro da coordenadora vira premissa.
48
48
 
49
49
  ### Esqueleto do brief (`DF-SF13.3-3`)
@@ -115,6 +115,7 @@ Nasceu de **7 erros da mesma classe numa única sessão** (2026-08-06/07), todos
115
115
  5. **Usuário testando a working tree principal:** rode **dev e QA em worktrees isoladas** (`isolation: worktree`), QA em porta alternativa, merge só no fim — zero interferência no ambiente do usuário. Como a branch fica travada na worktree do dev, o QA commita o filho direto e a **coordenadora** avança a branch com `git -C <worktree-dev> merge --ff-only`.
116
116
  6. **Antes de qualquer commit/escrita de estado próprio, a coordenadora confere `git branch --show-current` E o cwd.** Checkout residual de uma publicação anterior já fez a coordenadora commitar em branch errada (2 incidentes reais).
117
117
  7. **Em toda missão `*release`, a coordenadora revisa CHANGELOG e versão ANTES de apresentar o PR ao dono — nunca depois, nunca em paralelo.** O incidente da `0.16.0` (CHANGELOG apresentado como pronto e corrigido em paralelo com o dono já revisando) teve causa-raiz na ordem de operação da coordenadora, não na ausência de um gate de QA formal para `*release` — por isso a correção é de prática de orquestração, não de missão nova do `pc-devops`.
118
+ 8. **Duas stories na mesma branch: a 2ª a ser verificada reconfere as âncoras de linha que cita** em arquivos já tocados pela 1ª — **identidade de CONTEÚDO não implica identidade de POSIÇÃO**, e um `path:linha` correto quando a story foi escrita já aponta para outra linha depois do commit da irmã. Agrupar é decisão legítima de orquestração, mas tem esse custo: quem agrupa nomeia no brief os arquivos que as duas dividem, e a 2ª reconfere antes de citar. Origem: `FINDING-2` do gate `SF13.6` (`docs/qa/gates/sf13.6-fechamento-por-declaracao.yaml`), onde um mesmo agrupamento produziu 2 defeitos de classes diferentes — um AC que media `main...HEAD` passou a medir trabalho alheio (nomeado pelo dev) e as âncoras de linha da story irmã envelheceram (não mapeado por ninguém).
118
119
 
119
120
  ## Memória de papel — o caminho é curadoria humana, não pipeline (`DF-SF12.3-7`)
120
121