@polymorphism-tech/morph-spec 8.15.0 → 8.16.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/README.md +2 -2
- package/bin/morph-spec.js +8 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +4 -3
- package/framework/hooks/README.md +3 -3
- package/framework/hooks/claude-code/core/chain.js +123 -0
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
- package/framework/hooks/claude-code/core/gate-guard.js +21 -25
- package/framework/hooks/claude-code/core/loop-detect.js +70 -64
- package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
- package/framework/hooks/claude-code/core/protect-spec-files.js +110 -103
- package/framework/hooks/claude-code/core/state-sync.js +32 -40
- package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
- package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
- package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
- package/framework/hooks/claude-code/core/validate-completion.js +147 -107
- package/framework/hooks/claude-code/core/worktree-context.js +25 -22
- package/framework/hooks/claude-code/statusline.py +26 -4
- package/framework/hooks/shared/activity-logger.js +120 -69
- package/framework/hooks/shared/claim-registry.js +61 -1
- package/framework/hooks/shared/git-paths.js +94 -0
- package/framework/hooks/shared/hook-response.js +67 -0
- package/framework/hooks/shared/state-reader.js +95 -29
- package/framework/hooks/shared/state-writer.js +16 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/observability/cost.js +137 -0
- package/src/commands/project/doctor.js +28 -1
- package/src/commands/state/approve.js +30 -9
- package/src/commands/state/archive.js +29 -3
- package/src/commands/state/delete.js +36 -2
- package/src/dashboard/scanner/hooks-scanner.js +11 -2
- package/src/lib/installers/memory-installer.js +1 -1
- package/src/lib/telemetry/telemetry-reader.js +213 -0
- package/src/utils/hooks-installer.js +84 -79
- package/framework/hooks/shared/session-snapshot.js +0 -105
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
|
|
4
4
|
|
|
5
5
|
**Package:** `@polymorphism-tech/morph-spec`
|
|
6
|
-
**Version:** 8.
|
|
6
|
+
**Version:** 8.16.0
|
|
7
7
|
**Requires:** Node.js 22+, Claude Code
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -368,4 +368,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
|
|
|
368
368
|
|
|
369
369
|
---
|
|
370
370
|
|
|
371
|
-
*morph-spec v8.
|
|
371
|
+
*morph-spec v8.16.0 by [Polymorphism Tech](https://polymorphism.tech)*
|
package/bin/morph-spec.js
CHANGED
|
@@ -63,6 +63,7 @@ import { dashboardCommand } from '../src/commands/dashboard/dashboard.js';
|
|
|
63
63
|
|
|
64
64
|
// === Observability ===
|
|
65
65
|
import { telemetryCommand } from '../src/commands/observability/telemetry.js';
|
|
66
|
+
import { costCommand } from '../src/commands/observability/cost.js';
|
|
66
67
|
|
|
67
68
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
68
69
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -404,4 +405,11 @@ program
|
|
|
404
405
|
.option('--limit <n>', 'How many recent prompts to show (default 10)', (v) => parseInt(v, 10))
|
|
405
406
|
.action((options) => telemetryCommand(options));
|
|
406
407
|
|
|
408
|
+
program
|
|
409
|
+
.command('cost [feature]')
|
|
410
|
+
.description('Cost-facing view over .morph/logs/events.jsonl: task duration, bytes re-ingested by kind (proxy, not tokens), dispatch counts')
|
|
411
|
+
.option('--by <mode>', 'Group by: task|phase (default: feature-wide summary)')
|
|
412
|
+
.option('--json', 'Output the aggregate as JSON')
|
|
413
|
+
.action((feature, options) => costCommand(feature, options));
|
|
414
|
+
|
|
407
415
|
program.parse();
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
package/framework/CLAUDE.md
CHANGED
|
@@ -44,7 +44,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
44
44
|
| `/morph-troubleshoot [erro]` | Diagnóstico de erros .NET/Next.js com root-cause analysis |
|
|
45
45
|
|
|
46
46
|
**Comandos CLI de gate e de estado** (a LLM os usa em vez de editar o estado à mão):
|
|
47
|
-
- `morph-spec approve <feature> <gate> --mode auto` — aprova um gate (`proposal`/`plan`/`review`) em `trust: auto`. **O Gate 3 exige `4-review/evaluator-report.md`**: sem o relatório do avaliador independente o comando recusa (exit 1). Se o dispatch do avaliador for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate).
|
|
47
|
+
- `morph-spec approve <feature> <gate> --mode auto` — aprova um gate (`proposal`/`plan`/`review`) em `trust: auto`. **O Gate 3 exige `4-review/evaluator-report.md`**: sem o relatório do avaliador independente o comando recusa (exit 1). Se o dispatch do avaliador for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate). **Exceção `hotfix`:** esse tipo nunca despacha o avaliador (`morph-hotfix` §7); o Gate 3 exige `4-review/review-report.md` no lugar do `evaluator-report.md`, e a assinatura humana continua obrigatória via `--approver` (o backstop de `--mode auto` acima permanece recusando).
|
|
48
48
|
- `morph-spec advance <feature>` — aprova o gate da fase corrente **e** cria a pasta da próxima fase num passo só (`--design` toma o ramo opcional de uiux a partir da proposta).
|
|
49
49
|
- `morph-spec archive <feature>` — arquiva-**sem-integrar** (atômico, git-aware, commita o move escopado por pathspec): reservado a features legadas (sem branch `morph/{f}`) e faxina. O fechamento normal (arquiva **e** integra num passo) é `morph-spec finish`. É o que `/morph-archive` chama.
|
|
50
50
|
- `morph-spec finish <feature> --pr|--merge` — o **encerramento único** da feature (worktree ou comum): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — promovendo junto, no mesmo commit, os use cases da feature para o acervo permanente `.morph/specs/usecases/` com `status: implemented` (fail-open: sem `2-plan/usecases/`, no-op silencioso) — depois integra. `--merge` faz merge `--no-ff` **local** na default (sem push); `--pr` faz `push` + `gh pr create` (exige `origin` + `gh`, checados **antes** de arquivar — um `--pr` recusado não deixa meio-estado). Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; no `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`. Flags: `--keep` mantém o worktree, `--force` dispensa o Gate 3, `--discard-ignored` consente descartar gitignored no teardown. Substitui o antigo `worktree finish` + `archive` separados.
|
|
@@ -63,6 +63,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
63
63
|
- `morph-spec report-check <feature> [task] [--json]` — audita os relatórios de sub-agent contra o trabalho que realmente entrou. **Relatório ausente NÃO quer dizer que o agente falhou** — ele pode ter feito tudo e perdido o meio de gravar. O comando responde a pergunta certa (`work-landed-report-missing` → aceite degradado, NÃO re-despache; `no-work-no-report` → re-despache uma vez). Protocolo completo em MORPH.md §6.
|
|
64
64
|
- `morph-spec claim [feature] [--release] [--list] [--json]` — posse **advisory** de feature. Cada sessão reclama sua feature ativa automaticamente (hook SessionStart) e renova o heartbeat a cada tool call; o registro vive em `<git-common-dir>/morph-spec/claims.json`, compartilhado por todos os worktrees. **`morph-spec claim <feature>` (sem flags) ADQUIRE** — se esta árvore já tinha claim vivo em outra feature, ele é liberado no mesmo passo (o CLI reporta "movido de X para Y"); é o jeito de trocar de feature no meio da tarefa sem o dança de dois passos `--release` + reclamar. `--list` força a leitura sem adquirir mesmo passando uma feature. **Claims avisam, nunca bloqueiam** — mas se o SessionStart avisar que outra sessão tem claim vivo, **confirme antes de `finish`/`archive`/`delete`**: duas sessões na mesma feature já custaram um recap.md inteiro e um sub-agent (o worktree sumiu debaixo dele). Claim órfão de sessão morta: `morph-spec claim <f> --release`.
|
|
65
65
|
- `morph-spec telemetry [--json] [--feature <f>] [--limit <n>]` — reconstrói o caminho de execução da sessão a partir de `.morph/logs/events.jsonl` (skills invocadas, personas despachadas + prompts, standards/base de conhecimento lidos, mix de tools, prompts do usuário). Capturado silenciosamente pelo hook `telemetry-log` em todo tool call e todo UserPromptSubmit.
|
|
66
|
+
- `morph-spec cost [feature] [--by task|phase] [--json]` — custo da execução a partir do mesmo `events.jsonl`: duração por task (TaskStart→TaskEnd), bytes de contexto re-ingerido por tipo (`rubric`/`standard`/`skill`/`agents`/`plan-artifact`/`code`), dispatches + tamanho de prompt e mix de tools. Bytes re-ingeridos são **proxy determinística** de custo de token (tokens reais não são observáveis por hook). Use antes/depois de mudar skill ou fluxo para medir o efeito.
|
|
66
67
|
|
|
67
68
|
## Skills core (em `.claude/skills/`)
|
|
68
69
|
|
package/framework/MORPH.md
CHANGED
|
@@ -75,7 +75,7 @@ Anti-padrão: o Specialist não cria novas tarefas. Descobertas de escopo durant
|
|
|
75
75
|
|
|
76
76
|
**Evaluator** — ativo após uma tarefa ou fase ser concluída.
|
|
77
77
|
Responsabilidades: aplicar as rubricas em `.morph/framework/evals/rubrics/`, produzir score 0-10, emitir feedback priorizado quando score < 9 (que alimenta o loop de correção autônomo do morph-apply), escalar para revisão humana apenas quando o loop de correção se esgotar (3 tentativas sem atingir 9).
|
|
78
|
-
No **Gate 3** o Evaluator é obrigatoriamente um sub-agent independente: a `morph-review` despacha o subagent registrado `morph-evaluator` (gerado de `agents.json`, tools read-only) com contexto fresco — mandate + diff da implementação + saída real dos testes — e o score composto da feature vem desse retorno, nunca de auto-avaliação de quem implementou. **A regra é enforced server-side:** `morph-spec approve {feature} review` recusa (exit 1) enquanto `4-review/evaluator-report.md` não existir. Se o dispatch for genuinamente impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` não dispensa a regra (é só para ordem de gate).
|
|
78
|
+
No **Gate 3** o Evaluator é obrigatoriamente um sub-agent independente: a `morph-review` despacha o subagent registrado `morph-evaluator` (gerado de `agents.json`, tools read-only) com contexto fresco — mandate + diff da implementação + saída real dos testes — e o score composto da feature vem desse retorno, nunca de auto-avaliação de quem implementou. **A regra é enforced server-side:** `morph-spec approve {feature} review` recusa (exit 1) enquanto `4-review/evaluator-report.md` não existir. Se o dispatch for genuinamente impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` não dispensa a regra (é só para ordem de gate). **Exceção `hotfix`:** o tipo nunca despacha o avaliador (velocidade é o ponto do hotfix); o Gate 3 exige `4-review/review-report.md` — o artefato que a `morph-hotfix` de fato produz — e a assinatura humana continua obrigatória (§2.1/§5), sem afrouxar nada para os demais tipos.
|
|
79
79
|
Anti-padrão: o Evaluator não reescreve o código que avaliou. Se o score está abaixo de 6, o relatório vai para o humano — não para uma segunda rodada de tentativa automática.
|
|
80
80
|
|
|
81
81
|
Os papéis não são exclusivos — numa sessão longa você transita entre Orchestrator e Specialist conforme as tarefas avançam. A transição deve ser explícita: antes de mudar de papel, conclua o que estava fazendo ou registre o estado corrente no recap.md.
|
|
@@ -125,6 +125,7 @@ Features independentes podem ser desenvolvidas **em paralelo, cada uma no seu gi
|
|
|
125
125
|
- **Uma feature = um worktree = uma instância.** Nunca abra duas instâncias na mesma feature (o setup recusa com exit 3).
|
|
126
126
|
- O estado autoritativo (`feature.json`) viaja **commitado na branch** `morph/{f}`. O `state.json` local do worktree é um índice derivável — materializado por self-heal, nunca sincronizado à mão.
|
|
127
127
|
- Infra compartilhada (`node_modules`, `.claude`, `.morph/framework`) é linkada por junction; `memory/` e `logs/` são locais de cada worktree (isolamento correto). Se faltar infra num worktree (aberto com `git worktree add` puro), rode `morph-spec worktree link --all`.
|
|
128
|
+
- **Nunca apague um worktree à mão** (`rm -rf`, `Remove-Item -Recurse -Force`, deletar a pasta pelo explorador/IDE). O `morph-spec finish`/`worktree remove` desmonta a junction ANTES de remover (PROVE-or-DECLINE — nunca segue o link para fora); um `rm -rf`/`Remove-Item -Recurse` genérico não sabe disso e, em muitas implementações (inclusive o `Remove-Item -Recurse` do PowerShell no Windows), atravessa a junction e apaga o conteúdo real da raiz que ela aponta — o `.claude`/`node_modules`/`.morph/framework` compartilhados, não só o link. Se um worktree precisa sumir, use sempre `morph-spec finish <f> --pr|--merge` (ou, sem fechar a feature, `git worktree remove`, nunca uma remoção de diretório genérica).
|
|
128
129
|
- **Ambiente separado (portas):** o setup aloca um **bloco de portas determinístico** por feature e escreve `.morph/worktree.env` no worktree (`PORT`, `MORPH_PORT_RANGE`; `ASPNETCORE_URLS` quando o stack tem dotnet). **Suba dev servers SEMPRE nas portas do bloco** — as portas padrão (3000/5000) pertencem à raiz; duas instâncias no default se matam. O arquivo é local e git-invisível; o hook de SessionStart injeta as portas. Overrides em `config.json → fleet.basePort/blockSize`.
|
|
129
130
|
- **Banco de dados:** worktrees compartilham o mesmo banco por padrão. Para isolamento real com o MCP do Neon disponível, crie um branch de DB `morph/{f}` e anote a connection string no `.morph/worktree.env` (a CLI nunca toca em credenciais); sem Neon, prefixe schema/DB local com o nome da feature. Migration destrutiva num worktree compartilhando banco → integre antes de abrir worktrees irmãos.
|
|
130
131
|
- **Limitação conhecida:** `node_modules` é compartilhado (junction) — mudança de dependência feita num worktree afeta os irmãos. Mudou dependência: conclua e integre (`finish`) antes de seguir com worktrees paralelos.
|
|
@@ -245,7 +246,7 @@ Gates são pontos de **pausa potencial**. Em `trust: "auto"` (default), gates **
|
|
|
245
246
|
- Sinais: `evalScore` composite, `standardsViolations` detectadas em implementation.
|
|
246
247
|
- Skill responsável: `morph-review`.
|
|
247
248
|
|
|
248
|
-
**Gates por tipo de trabalho:** `chore` e `hotfix` não passam pelos Gates 1 e 2 — eles são carimbados por política no nascimento (`approvedBy: "policy:{tipo}"`, nunca `"auto"`, para não colidir com o gate-guard). A barra de qualidade do morph-eval e o Gate 3 continuam valendo para todos os tipos. Para `hotfix`, o Gate 3 é **sempre humano**: mesmo em `trust: auto` a `morph-hotfix` apresenta o review via `AskUserQuestion` e o `risk-detector` força a pausa; o backstop server-side (`morph-spec approve ... review --mode auto` recusa) garante que nenhum caminho auto passe por cima.
|
|
249
|
+
**Gates por tipo de trabalho:** `chore` e `hotfix` não passam pelos Gates 1 e 2 — eles são carimbados por política no nascimento (`approvedBy: "policy:{tipo}"`, nunca `"auto"`, para não colidir com o gate-guard). A barra de qualidade do morph-eval e o Gate 3 continuam valendo para todos os tipos. Para `hotfix`, o Gate 3 é **sempre humano**: mesmo em `trust: auto` a `morph-hotfix` apresenta o review via `AskUserQuestion` e o `risk-detector` força a pausa; o backstop server-side (`morph-spec approve ... review --mode auto` recusa) garante que nenhum caminho auto passe por cima. O artefato exigido também difere: `hotfix` nunca despacha o avaliador independente, então o Gate 3 exige `4-review/review-report.md` em vez de `evaluator-report.md`; todos os outros tipos continuam exigindo o `evaluator-report.md` (ou `--self-assessed` registrado).
|
|
249
250
|
|
|
250
251
|
Para alterar o modo: editar `.morph/config/config.json → trust` (`"auto"` ou `"manual"`). Configs sem o campo `trust` defaultam para `"auto"`.
|
|
251
252
|
|
|
@@ -401,4 +402,4 @@ Nenhuma skill deve ser invocada proativamente por um hook ou script automático.
|
|
|
401
402
|
> Alterações de comportamento do harness exigem atualização aqui, não em hooks ou scripts.
|
|
402
403
|
> Versão inicial criada em 2026-04-24. Próxima revisão recomendada após o ciclo completo com NLH.
|
|
403
404
|
>
|
|
404
|
-
> Para histórico de decisões sobre este documento, consulte os commits do arquivo
|
|
405
|
+
> Para histórico de decisões sobre este documento, consulte os commits do arquivo.
|
|
@@ -46,15 +46,15 @@ framework/hooks/
|
|
|
46
46
|
| **PreToolUse** (Write\|Edit) | protect-spec-files.js | Block | Blocks edits to spec files after approval; for tasks.json validates the mutable-field diff, the status enum, and requires a `notes` when `outputs` diverge from the plan |
|
|
47
47
|
| **PreToolUse** (Write\|Edit) | enforce-phase-writes.js | Block | Ensures writes go to current phase directory |
|
|
48
48
|
| **PreToolUse** (Bash) | pre-bash-denylist.js | Block | Blocks `rm -rf .morph/` and direct state edits |
|
|
49
|
-
| **PostToolUse** (Write\|Edit) | post-edit-typecheck.js | Advisory | Debounced `tsc --noEmit` after TS/TSX edits; silent on success |
|
|
49
|
+
| **PostToolUse** (Write\|Edit) | post-edit-typecheck.js | Advisory | Debounced `tsc --noEmit` after TS/TSX edits; silent on success. Also touches `.morph/memory/build-dirty.json` on every source-file edit — the write side of validate-completion.js's Stop-hook build-dirty gate |
|
|
50
50
|
| **PostToolUse** (Write\|Edit) | tasks-json-guard.js | Advisory | Validates the written tasks.json schema (status/effort enums); warns so the LLM fixes it immediately |
|
|
51
51
|
| **PostToolUse** (Write\|Edit) | trace-autogen.js | State sync | Auto-generates `.morph/traces/YYYY-MM-DD-{feature}-implement.json` from tasks.json + recap.md + taskScores on every tasks.json write — the skills never write traces by hand |
|
|
52
52
|
| **PostToolUse** (Write\|Edit\|AskUserQuestion) | state-sync.js | State sync | Autonomous state updates (feature register, output tracking, gate approval) |
|
|
53
|
-
| **PostToolUse** (Write\|Edit\|AskUserQuestion) | gate-guard.js | Advisory | Warns (never blocks) when a gate action disagrees with the persisted gateDecisions; logs one `coherent`/`divergent` line per check in `.morph/logs/activity.
|
|
53
|
+
| **PostToolUse** (Write\|Edit\|AskUserQuestion) | gate-guard.js | Advisory | Warns (never blocks) when a gate action disagrees with the persisted gateDecisions; logs one `coherent`/`divergent` line per check in `.morph/logs/activity.jsonl` (issue #63) |
|
|
54
54
|
| **PostToolUse** (all tools) | telemetry-log.js | Telemetry | Appends every tool call (skill, sub-agent dispatch + prompt, knowledge-base read, bash/edit) to `.morph/logs/events.jsonl`; silent, fail-open — the NLH execution-path capture read back by `morph-spec telemetry` |
|
|
55
55
|
| **PostToolUse** (all tools) | loop-detect.js | Advisory | Warns when the last 8 actions collapse to ≤2 unique signatures |
|
|
56
56
|
| **UserPromptSubmit** | telemetry-log.js | Telemetry | Appends the user's prompt to the same `events.jsonl` stream; silent, fail-open |
|
|
57
|
-
| **Stop** | validate-completion.js | Advisory | Warns about incomplete tasks/missing outputs/recap drift; build check
|
|
57
|
+
| **Stop** | validate-completion.js | Advisory | Warns about incomplete tasks/missing outputs/recap drift (hash-deduped, only re-injects when the warning set changes); dirty-flag-gated build check |
|
|
58
58
|
|
|
59
59
|
## Design Principles
|
|
60
60
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* chain.js — roda vários hooks core num único processo node.
|
|
4
|
+
*
|
|
5
|
+
* Motivação (onda de performance R2): cada hook registrado no Claude Code é
|
|
6
|
+
* hoje um processo node próprio (~60ms de cold start). Um único Write/Edit
|
|
7
|
+
* disparava até 9 desses processos — 2 PreToolUse + 3+2+2 PostToolUse. Este
|
|
8
|
+
* script agrupa os elos de um mesmo event+matcher (a mesma entrada do
|
|
9
|
+
* registry em src/utils/hooks-installer.js) num só spawn: lê stdin UMA vez,
|
|
10
|
+
* importa e chama `run(payload)` de cada hook em sequência, e combina as
|
|
11
|
+
* respostas — reduzindo esses 9 spawns para 4 por Write/Edit (1 PreToolUse +
|
|
12
|
+
* 3 PostToolUse: Write|Edit / Write|Edit|AskUserQuestion / matcher-`*`).
|
|
13
|
+
*
|
|
14
|
+
* Uso: `node chain.js <hook1> <hook2> ...` — nomes de arquivo SEM `.js`,
|
|
15
|
+
* resolvidos no mesmo diretório deste script (core/), na ORDEM em que devem
|
|
16
|
+
* rodar. A ordem é a mesma que o registry antigo (duas entries separadas)
|
|
17
|
+
* já implicava — ver o comentário de cada grupo em hooks-installer.js.
|
|
18
|
+
*
|
|
19
|
+
* Cada hook core exporta `run(payload)` (ver framework/hooks/shared/hook-response.js
|
|
20
|
+
* `runStandalone`) — o arquivo continua executável standalone (spawnSync
|
|
21
|
+
* direto, como os testes já fazem) porque a própria cauda do arquivo só lê
|
|
22
|
+
* stdin/imprime/sai quando NÃO está sendo importado por esta cadeia.
|
|
23
|
+
*
|
|
24
|
+
* Regras de combinação:
|
|
25
|
+
* - PreToolUse (decision:'block'): o PRIMEIRO elo que retornar um bloqueio
|
|
26
|
+
* VENCE e encerra a cadeia — os elos seguintes nem chegam a rodar. Isto é
|
|
27
|
+
* uma otimização NOVA desta consolidação (antes, Claude Code podia
|
|
28
|
+
* disparar os dois processos de qualquer forma); o resultado observável
|
|
29
|
+
* pro Claude Code é idêntico (o tool call é bloqueado do mesmo jeito).
|
|
30
|
+
* - PostToolUse / outros eventos de contexto: os textos de TODOS os elos que
|
|
31
|
+
* retornarem `hookSpecificOutput.additionalContext` (ou `decision:'approve'`
|
|
32
|
+
* com additionalContext) são concatenados, na ORDEM dos argv, separados por
|
|
33
|
+
* uma linha em branco — nenhum elo é pulado por um vizinho ter respondido.
|
|
34
|
+
* - Nome do evento (hookSpecificOutput.hookEventName) no resultado final:
|
|
35
|
+
* o valor que o PRIMEIRO elo a declarar um venceu (mesma regra de
|
|
36
|
+
* "primeiro vence" do bloqueio acima — na prática todos os elos de um
|
|
37
|
+
* mesmo grupo observam o MESMO evento real do Claude Code, então isto só
|
|
38
|
+
* importa como desempate determinístico); na ausência de qualquer elo
|
|
39
|
+
* declarando, cai para `payload.hook_event_name`; por último, `'PostToolUse'`.
|
|
40
|
+
* - Fail-open POR ELO: uma exceção não tratada por um hook individual (import
|
|
41
|
+
* falhou, run() não existe, etc.) não derruba a cadeia — os elos seguintes
|
|
42
|
+
* ainda rodam. Cada hook já fail-opens sozinho dentro do seu próprio
|
|
43
|
+
* try/catch; isto é a segunda rede de segurança, para o import em si.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { dirname, join } from 'path';
|
|
47
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
48
|
+
import { readStdin } from '../../shared/stdin-reader.js';
|
|
49
|
+
|
|
50
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
|
|
52
|
+
// Marca "já estou dentro de uma cadeia" para os hooks importados abaixo: a
|
|
53
|
+
// cauda de execução standalone de cada hook (runStandalone() em
|
|
54
|
+
// hook-response.js) só dispara quando esta flag NÃO está setada. Setada
|
|
55
|
+
// ANTES do primeiro import dinâmico — cada hook importado herda o mesmo
|
|
56
|
+
// process.env deste processo.
|
|
57
|
+
process.env.MORPH_HOOK_CHAINED = '1';
|
|
58
|
+
|
|
59
|
+
// Override só para testes da mecânica genérica desta cadeia (fixtures fora
|
|
60
|
+
// de core/, sem precisar ser hooks reais nem satisfazer as convenções que
|
|
61
|
+
// hook-wiring.test.js exige de todo arquivo em core/). Produção nunca seta
|
|
62
|
+
// isto — resolve sempre no próprio diretório deste script.
|
|
63
|
+
const coreDir = process.env.MORPH_HOOK_CORE_DIR || __dirname;
|
|
64
|
+
|
|
65
|
+
/** Extrai o texto de contexto de um resultado de hook, se houver. */
|
|
66
|
+
function contextOf(result) {
|
|
67
|
+
if (!result) return null;
|
|
68
|
+
if (result.decision === 'approve' && typeof result.additionalContext === 'string') {
|
|
69
|
+
return result.additionalContext;
|
|
70
|
+
}
|
|
71
|
+
const ctx = result?.hookSpecificOutput?.additionalContext;
|
|
72
|
+
return typeof ctx === 'string' && ctx ? ctx : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function main() {
|
|
76
|
+
const names = process.argv.slice(2);
|
|
77
|
+
const payload = await readStdin();
|
|
78
|
+
|
|
79
|
+
const contexts = [];
|
|
80
|
+
let eventName = null;
|
|
81
|
+
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
let result = null;
|
|
84
|
+
try {
|
|
85
|
+
const mod = await import(pathToFileURL(join(coreDir, `${name}.js`)).href);
|
|
86
|
+
result = await mod.run(payload);
|
|
87
|
+
} catch {
|
|
88
|
+
// Fail-open POR ELO — ver doc do módulo acima.
|
|
89
|
+
result = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!result) continue;
|
|
93
|
+
|
|
94
|
+
// PreToolUse: o primeiro bloqueio vence e encerra a cadeia.
|
|
95
|
+
if (result.decision === 'block') {
|
|
96
|
+
console.log(JSON.stringify(result));
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ctx = contextOf(result);
|
|
101
|
+
if (ctx) {
|
|
102
|
+
contexts.push(ctx);
|
|
103
|
+
// First link to declare a name wins (mirrors block-first-wins above) —
|
|
104
|
+
// in practice every link in a group observes the SAME real Claude Code
|
|
105
|
+
// event, so this only matters as a deterministic tie-break.
|
|
106
|
+
eventName = eventName || result?.hookSpecificOutput?.hookEventName;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (contexts.length === 0) {
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(JSON.stringify({
|
|
115
|
+
hookSpecificOutput: {
|
|
116
|
+
hookEventName: eventName || payload?.hook_event_name || 'PostToolUse',
|
|
117
|
+
additionalContext: contexts.join('\n\n'),
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
main().catch(() => process.exit(0));
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { existsSync } from 'fs';
|
|
21
|
-
import { readStdin } from '../../shared/stdin-reader.js';
|
|
22
21
|
import { stateExists, getFeaturePhase, isGateApproved } from '../../shared/state-reader.js';
|
|
23
22
|
import {
|
|
24
23
|
isFeaturePath,
|
|
@@ -40,88 +39,96 @@ const PHASE_DIR_ENTRY_GATE = {
|
|
|
40
39
|
'3-implement': 'plan',
|
|
41
40
|
'4-review': 'plan',
|
|
42
41
|
};
|
|
43
|
-
import {
|
|
42
|
+
import { blockResult, runStandalone } from '../../shared/hook-response.js';
|
|
44
43
|
import { logHookActivity } from '../../shared/activity-logger.js';
|
|
45
44
|
import { getFilePath, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (!filePath) pass();
|
|
59
|
-
|
|
60
|
-
// Only check files inside .morph/features/
|
|
61
|
-
if (!isFeaturePath(filePath)) pass();
|
|
62
|
-
|
|
63
|
-
// Resolve the tree that OWNS this file, NOT the tree the session sits in.
|
|
64
|
-
// A session rooted in the primary repo may legitimately write to a feature
|
|
65
|
-
// living in a secondary worktree; reading the session's `.morph/` there
|
|
66
|
-
// reported the ROOT's phase for the WORKTREE's file, and blocked a legal
|
|
67
|
-
// `2-plan/` write with "not allowed during PROPOSAL phase". `stateExists`
|
|
68
|
-
// must be asked of that same tree — a worktree carries its own state.
|
|
69
|
-
const root = resolveMorphRoot(filePath, process.cwd());
|
|
70
|
-
if (!stateExists(root)) pass();
|
|
71
|
-
|
|
72
|
-
const featureName = extractFeatureName(filePath);
|
|
73
|
-
if (!featureName) pass();
|
|
74
|
-
|
|
75
|
-
const phase = getFeaturePhase(featureName, root);
|
|
76
|
-
if (!phase) pass();
|
|
77
|
-
|
|
78
|
-
// During implement phase, all writes are allowed (source code is unrestricted)
|
|
79
|
-
if (phase === 'implement' || phase === 'sync') pass();
|
|
80
|
-
|
|
81
|
-
const targetDir = extractPhaseDir(filePath);
|
|
82
|
-
if (!targetDir) pass(); // File directly in feature root — allow
|
|
83
|
-
|
|
84
|
-
const allowedDir = PHASE_DIRS[phase];
|
|
85
|
-
if (!allowedDir) pass(); // Unknown phase — allow
|
|
86
|
-
|
|
87
|
-
if (targetDir !== allowedDir) {
|
|
88
|
-
// Allow editing already-existing files from previous phases
|
|
89
|
-
const absolutePath = resolveTargetPath(filePath, root);
|
|
90
|
-
if (existsSync(absolutePath)) {
|
|
91
|
-
logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
|
|
92
|
-
pass(); // editing existing file — allow
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} payload - parsed PreToolUse stdin payload
|
|
48
|
+
* @returns {Promise<object|null>} `{decision:'block',reason}` or null (pass).
|
|
49
|
+
* Never prints, never exits — see runStandalone()/chain.js at the bottom.
|
|
50
|
+
*/
|
|
51
|
+
export async function run(payload) {
|
|
52
|
+
try {
|
|
53
|
+
// Amend mode: bypass phase write enforcement for legitimate corrections
|
|
54
|
+
if (process.env.MORPH_AMEND_PHASE) {
|
|
55
|
+
console.error(`[morph-spec] AMEND MODE: bypassing phase protection for phase '${process.env.MORPH_AMEND_PHASE}'`);
|
|
56
|
+
return null;
|
|
93
57
|
}
|
|
94
58
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
59
|
+
if (!payload) return null;
|
|
60
|
+
|
|
61
|
+
const filePath = getFilePath(payload);
|
|
62
|
+
if (!filePath) return null;
|
|
63
|
+
|
|
64
|
+
// Only check files inside .morph/features/
|
|
65
|
+
if (!isFeaturePath(filePath)) return null;
|
|
66
|
+
|
|
67
|
+
// Resolve the tree that OWNS this file, NOT the tree the session sits in.
|
|
68
|
+
// A session rooted in the primary repo may legitimately write to a feature
|
|
69
|
+
// living in a secondary worktree; reading the session's `.morph/` there
|
|
70
|
+
// reported the ROOT's phase for the WORKTREE's file, and blocked a legal
|
|
71
|
+
// `2-plan/` write with "not allowed during PROPOSAL phase". `stateExists`
|
|
72
|
+
// must be asked of that same tree — a worktree carries its own state.
|
|
73
|
+
const root = resolveMorphRoot(filePath, process.cwd());
|
|
74
|
+
if (!stateExists(root)) return null;
|
|
75
|
+
|
|
76
|
+
const featureName = extractFeatureName(filePath);
|
|
77
|
+
if (!featureName) return null;
|
|
78
|
+
|
|
79
|
+
const phase = getFeaturePhase(featureName, root);
|
|
80
|
+
if (!phase) return null;
|
|
81
|
+
|
|
82
|
+
// During implement phase, all writes are allowed (source code is unrestricted)
|
|
83
|
+
if (phase === 'implement' || phase === 'sync') return null;
|
|
84
|
+
|
|
85
|
+
const targetDir = extractPhaseDir(filePath);
|
|
86
|
+
if (!targetDir) return null; // File directly in feature root — allow
|
|
87
|
+
|
|
88
|
+
const allowedDir = PHASE_DIRS[phase];
|
|
89
|
+
if (!allowedDir) return null; // Unknown phase — allow
|
|
90
|
+
|
|
91
|
+
if (targetDir !== allowedDir) {
|
|
92
|
+
// Allow editing already-existing files from previous phases
|
|
93
|
+
const absolutePath = resolveTargetPath(filePath, root);
|
|
94
|
+
if (existsSync(absolutePath)) {
|
|
95
|
+
logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
|
|
96
|
+
return null; // editing existing file — allow
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Gate-aware forward transition: allow creating the NEXT phase's first
|
|
100
|
+
// artifact as soon as the gate authorizing that phase is approved. Without
|
|
101
|
+
// this, the first file of a new phase can never be written (the phase only
|
|
102
|
+
// advances once that file exists) — the deadlock this hook used to create.
|
|
103
|
+
const entryGate = PHASE_DIR_ENTRY_GATE[targetDir];
|
|
104
|
+
if (entryGate && isGateApproved(featureName, entryGate, root)) {
|
|
105
|
+
logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const phaseLabel = phase.toUpperCase();
|
|
110
|
+
// Name the tree the verdict came from whenever it is NOT the session's own.
|
|
111
|
+
// The phase read is per-tree, so "which .morph/ said proposal?" is the first
|
|
112
|
+
// question anyone debugging this asks.
|
|
113
|
+
const treeNote = root !== process.cwd().replace(/\\/g, '/')
|
|
114
|
+
? `\n(phase read from the tree that owns the file: ${root})`
|
|
115
|
+
: '';
|
|
116
|
+
logHookActivity('enforce-phase-writes', 'PreToolUse', 'blocked');
|
|
117
|
+
return blockResult(
|
|
118
|
+
`MORPH-SPEC: Writing to '${targetDir}/' is not allowed during ${phaseLabel} phase.\n` +
|
|
119
|
+
`Current phase '${phase}' only permits writes to '${allowedDir}/'.${treeNote}\n\n` +
|
|
120
|
+
`To move forward, approve the current gate and scaffold the next phase in\n` +
|
|
121
|
+
`one step: morph-spec advance ${featureName}\n` +
|
|
122
|
+
`(run it in that tree — the gate is recorded in its own feature.json).`
|
|
123
|
+
);
|
|
103
124
|
}
|
|
104
125
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
? `\n(phase read from the tree that owns the file: ${root})`
|
|
111
|
-
: '';
|
|
112
|
-
logHookActivity('enforce-phase-writes', 'PreToolUse', 'blocked');
|
|
113
|
-
block(
|
|
114
|
-
`MORPH-SPEC: Writing to '${targetDir}/' is not allowed during ${phaseLabel} phase.\n` +
|
|
115
|
-
`Current phase '${phase}' only permits writes to '${allowedDir}/'.${treeNote}\n\n` +
|
|
116
|
-
`To move forward, approve the current gate and scaffold the next phase in\n` +
|
|
117
|
-
`one step: morph-spec advance ${featureName}\n` +
|
|
118
|
-
`(run it in that tree — the gate is recorded in its own feature.json).`
|
|
119
|
-
);
|
|
126
|
+
logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
|
|
127
|
+
return null;
|
|
128
|
+
} catch {
|
|
129
|
+
// Fail-open
|
|
130
|
+
return null;
|
|
120
131
|
}
|
|
121
|
-
|
|
122
|
-
logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
|
|
123
|
-
pass();
|
|
124
|
-
} catch {
|
|
125
|
-
// Fail-open
|
|
126
|
-
process.exit(0);
|
|
127
132
|
}
|
|
133
|
+
|
|
134
|
+
await runStandalone(run);
|
|
@@ -24,21 +24,12 @@
|
|
|
24
24
|
* No persisted decision, non-gate action, or any error → silence.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { readFileSync } from 'fs';
|
|
28
27
|
import { loadState, stateExists, getActiveFeature } from '../../shared/state-reader.js';
|
|
29
|
-
import {
|
|
28
|
+
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
30
29
|
import { logHookActivity } from '../../shared/activity-logger.js';
|
|
31
30
|
import { resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
|
|
32
31
|
import { locateFeatureForQuestion } from '../../shared/feature-locator.js';
|
|
33
32
|
|
|
34
|
-
function readStdin() {
|
|
35
|
-
try {
|
|
36
|
-
return JSON.parse(readFileSync(0, 'utf8') || '{}');
|
|
37
|
-
} catch {
|
|
38
|
-
return {};
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
33
|
function projectDir() {
|
|
43
34
|
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
44
35
|
}
|
|
@@ -118,7 +109,7 @@ function checkAskUserQuestion(input, sessionRoot) {
|
|
|
118
109
|
if (!decision) continue; // no persisted decision → nothing to verify
|
|
119
110
|
if (decision.pause === false) {
|
|
120
111
|
logHookActivity('gate-guard', 'PostToolUse', 'divergent', cwd);
|
|
121
|
-
return
|
|
112
|
+
return contextResult(
|
|
122
113
|
`⚠️ [morph-spec] GATE OVER-PAUSE — você apresentou o Gate "${gate}" via AskUserQuestion, ` +
|
|
123
114
|
`mas a decisão registrada em state.json (gateDecisions.${gate}) era pause:false ` +
|
|
124
115
|
`(trust:${decision.trust ?? 'auto'}, nenhum sinal de risco). ` +
|
|
@@ -130,9 +121,9 @@ function checkAskUserQuestion(input, sessionRoot) {
|
|
|
130
121
|
}
|
|
131
122
|
// The decision said pause:true and the LLM did pause — coherent.
|
|
132
123
|
logHookActivity('gate-guard', 'PostToolUse', 'coherent', cwd);
|
|
133
|
-
return
|
|
124
|
+
return null;
|
|
134
125
|
}
|
|
135
|
-
return
|
|
126
|
+
return null;
|
|
136
127
|
}
|
|
137
128
|
|
|
138
129
|
/** True for edits to .morph/state.json (legacy) or a per-feature feature.json (v5). */
|
|
@@ -149,17 +140,17 @@ function isGateStateFile(filePath) {
|
|
|
149
140
|
*/
|
|
150
141
|
function checkStateEdit(input, sessionRoot) {
|
|
151
142
|
const filePath = input.tool_input?.file_path;
|
|
152
|
-
if (!isGateStateFile(filePath)) return
|
|
143
|
+
if (!isGateStateFile(filePath)) return null;
|
|
153
144
|
|
|
154
145
|
// The edited file names its own tree — and, for the v5 per-feature
|
|
155
146
|
// feature.json, its own feature. Only the legacy `.morph/state.json` shape
|
|
156
147
|
// still needs a most-recently-updated fallback within that tree.
|
|
157
148
|
const cwd = rootOfGateStateFile(filePath, sessionRoot);
|
|
158
|
-
if (!stateExists(cwd)) return
|
|
149
|
+
if (!stateExists(cwd)) return null; // fail-open
|
|
159
150
|
const state = loadState(cwd);
|
|
160
|
-
if (!state) return
|
|
151
|
+
if (!state) return null;
|
|
161
152
|
const feature = featureFromStatePath(filePath) || getActiveFeature(cwd)?.name;
|
|
162
|
-
if (!feature) return
|
|
153
|
+
if (!feature) return null;
|
|
163
154
|
|
|
164
155
|
let checked = false;
|
|
165
156
|
const gates = state.features?.[feature]?.approvalGates ?? {};
|
|
@@ -171,7 +162,7 @@ function checkStateEdit(input, sessionRoot) {
|
|
|
171
162
|
if (!decision) continue;
|
|
172
163
|
if (decision.pause === true) {
|
|
173
164
|
logHookActivity('gate-guard', 'PostToolUse', 'divergent', cwd);
|
|
174
|
-
return
|
|
165
|
+
return contextResult(
|
|
175
166
|
`⚠️ [morph-spec] GATE SKIPPED PAUSE — o Gate "${gate}" foi marcado approvedBy:"auto", ` +
|
|
176
167
|
`mas a decisão registrada (gateDecisions.${gate}) era pause:true ` +
|
|
177
168
|
`(sinal: ${decision.reason || 'risco detectado'}). ` +
|
|
@@ -183,24 +174,29 @@ function checkStateEdit(input, sessionRoot) {
|
|
|
183
174
|
checked = true;
|
|
184
175
|
}
|
|
185
176
|
if (checked) logHookActivity('gate-guard', 'PostToolUse', 'coherent', cwd);
|
|
186
|
-
return
|
|
177
|
+
return null;
|
|
187
178
|
}
|
|
188
179
|
|
|
189
|
-
|
|
180
|
+
/**
|
|
181
|
+
* @param {object} payload - parsed PostToolUse stdin payload (Write|Edit|AskUserQuestion)
|
|
182
|
+
* @returns {Promise<object|null>} a hookSpecificOutput warning on a
|
|
183
|
+
* trust-mode divergence, or null (pass/silent).
|
|
184
|
+
*/
|
|
185
|
+
export async function run(payload) {
|
|
190
186
|
// Starting point only — each branch resolves the tree it actually inspects
|
|
191
187
|
// (from the edited path, or from the feature the question names).
|
|
192
188
|
const sessionRoot = projectDir();
|
|
193
189
|
|
|
194
|
-
const input =
|
|
190
|
+
const input = payload || {};
|
|
195
191
|
const toolName = input.tool_name;
|
|
196
192
|
|
|
197
193
|
// Short-circuit before loading state: a Write/Edit only matters if it
|
|
198
194
|
// touched gate state (state.json legacy, or the v5 per-feature
|
|
199
195
|
// feature.json). Skip the disk read for unrelated edits.
|
|
200
196
|
if (toolName === 'Edit' || toolName === 'Write') {
|
|
201
|
-
if (!isGateStateFile(input.tool_input?.file_path)) return
|
|
197
|
+
if (!isGateStateFile(input.tool_input?.file_path)) return null;
|
|
202
198
|
} else if (toolName !== 'AskUserQuestion') {
|
|
203
|
-
return
|
|
199
|
+
return null;
|
|
204
200
|
}
|
|
205
201
|
|
|
206
202
|
try {
|
|
@@ -209,7 +205,7 @@ function main() {
|
|
|
209
205
|
} catch {
|
|
210
206
|
// fail-open
|
|
211
207
|
}
|
|
212
|
-
return
|
|
208
|
+
return null;
|
|
213
209
|
}
|
|
214
210
|
|
|
215
|
-
|
|
211
|
+
await runStandalone(run);
|