@polymorphism-tech/morph-spec 8.20.0 → 8.22.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 +31 -2
- 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 +6 -2
- package/framework/agents.json +3 -2
- package/framework/docs/CLI.md +4 -3
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +23 -2
- package/framework/hooks/claude-code/core/state-sync.js +5 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +34 -3
- package/framework/hooks/claude-code/core/validate-completion.js +116 -4
- package/framework/hooks/claude-code/core/worktree-context.js +9 -4
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/telemetry-logger.js +64 -1
- package/framework/schemas/feature.schema.json +5 -3
- package/framework/schemas/tasks.schema.json +1 -1
- 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 +7 -16
- package/framework/skills/core/morph-brainstorming/SKILL.md +2 -2
- 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 +25 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +35 -8
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +29 -15
- package/framework/skills/core/morph-uiux/SKILL.md +3 -3
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +29 -5
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +318 -0
- package/src/commands/observability/cost.js +93 -5
- package/src/commands/project/update.js +2 -63
- package/src/commands/state/finish.js +38 -0
- package/src/commands/state/gate-check.js +10 -3
- package/src/commands/validation/validate-feature.js +7 -1
- package/src/commands/validation/verify.js +7 -0
- package/src/core/templates/template-registry.js +2 -30
- package/src/lib/e2e/compose-env.js +74 -0
- package/src/lib/git/default-branch.js +44 -8
- package/src/lib/installers/templates-reconcile.js +81 -0
- package/src/lib/risk-detector.js +20 -1
- package/src/lib/telemetry/telemetry-reader.js +80 -4
- package/src/lib/validators/verify-runner.js +121 -2
- package/src/scripts/setup-infra.js +23 -5
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.22.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.22.0 by [Polymorphism Tech](https://polymorphism.tech)*
|
package/bin/morph-spec.js
CHANGED
|
@@ -50,6 +50,7 @@ import { dagCommand } from '../src/commands/tasks/dag.js';
|
|
|
50
50
|
import { validateCommand } from './validate.js';
|
|
51
51
|
import { validateFeatureCommand } from '../src/commands/validation/validate-feature.js';
|
|
52
52
|
import { verifyCommand } from '../src/commands/validation/verify.js';
|
|
53
|
+
import { e2eUpCommand, e2eStatusCommand, e2eDownCommand } from '../src/commands/e2e/e2e.js';
|
|
53
54
|
|
|
54
55
|
// === Scaffolding ===
|
|
55
56
|
import { scaffoldDotnetCommand, scaffoldNextjsCommand } from '../src/commands/scaffold/scaffold.js';
|
|
@@ -293,12 +294,14 @@ program
|
|
|
293
294
|
|
|
294
295
|
program
|
|
295
296
|
.command('verify <feature> [task]')
|
|
296
|
-
.description('Run the deterministic verification chain (build + tests + validators) — code before the LLM judge')
|
|
297
|
+
.description('Run the deterministic verification chain (build + tests + validators + e2e) — code before the LLM judge')
|
|
297
298
|
.option('--json', 'Output as JSON')
|
|
298
299
|
.option('--rubric <id>', 'Force a rubric (default: by task group → config → default)')
|
|
299
300
|
.option('--timeout <ms>', 'Build timeout in ms (default 120000)', (v) => parseInt(v, 10))
|
|
300
301
|
.option('--skip-tests', 'Skip the test node')
|
|
301
302
|
.option('--skip-build', 'Skip the build node')
|
|
303
|
+
.option('--skip-e2e', 'Skip the e2e node (Docker bring-up + Playwright)')
|
|
304
|
+
.option('--fresh-e2e', 'Rebuild the e2e stack instead of reusing a warm one')
|
|
302
305
|
.option('--no-persist', 'Do not persist the result to feature.json')
|
|
303
306
|
.action((feature, task, options) =>
|
|
304
307
|
verifyCommand(feature, task, { ...options, noPersist: options.persist === false })
|
|
@@ -351,6 +354,31 @@ contextCommand
|
|
|
351
354
|
.option('--json', 'Output scan result as JSON')
|
|
352
355
|
.action((options) => contextRefreshCommand(options));
|
|
353
356
|
|
|
357
|
+
// ─── E2E stack (Docker bring-up for runtime verification) ─────────────────
|
|
358
|
+
const e2eCommand = program
|
|
359
|
+
.command('e2e')
|
|
360
|
+
.description("Lifecycle of the feature's verification stack (docker compose)");
|
|
361
|
+
|
|
362
|
+
e2eCommand
|
|
363
|
+
.command('up <feature>')
|
|
364
|
+
.description('Bring the stack up (reusing a warm one when it still matches the working tree)')
|
|
365
|
+
.option('--fresh', 'Rebuild instead of reusing a warm stack')
|
|
366
|
+
.option('--json', 'Output as JSON')
|
|
367
|
+
.action((feature, options) => e2eUpCommand(feature, options));
|
|
368
|
+
|
|
369
|
+
e2eCommand
|
|
370
|
+
.command('status <feature>')
|
|
371
|
+
.description('Is the stack up, healthy, and built from the current source? Exit 0 only if all three')
|
|
372
|
+
.option('--json', 'Output as JSON')
|
|
373
|
+
.action((feature, options) => e2eStatusCommand(feature, options));
|
|
374
|
+
|
|
375
|
+
e2eCommand
|
|
376
|
+
.command('down <feature>')
|
|
377
|
+
.description('Tear the stack down (best-effort — never blocks)')
|
|
378
|
+
.option('--volumes', 'Also remove named volumes')
|
|
379
|
+
.option('--json', 'Output as JSON')
|
|
380
|
+
.action((feature, options) => e2eDownCommand(feature, options));
|
|
381
|
+
|
|
354
382
|
// ─── Worktree (optional, isolation) ───────────────────────────────────────
|
|
355
383
|
const worktreeCommand = program
|
|
356
384
|
.command('worktree')
|
|
@@ -428,7 +456,8 @@ program
|
|
|
428
456
|
program
|
|
429
457
|
.command('cost [feature]')
|
|
430
458
|
.description('Cost-facing view over .morph/logs/events.jsonl: task duration, bytes re-ingested by kind (proxy, not tokens), dispatch counts')
|
|
431
|
-
.option('--by <mode>', 'Group by: task|phase (default: feature-wide summary)')
|
|
459
|
+
.option('--by <mode>', 'Group by: task|phase (default: feature-wide summary; with --all-trees, applies to the combined aggregate)')
|
|
460
|
+
.option('--all-trees', 'Aggregate .morph/logs/events.jsonl across every git worktree that has one, not just the current tree — per-tree breakdown + combined total')
|
|
432
461
|
.option('--json', 'Output the aggregate as JSON')
|
|
433
462
|
.action((feature, options) => costCommand(feature, options));
|
|
434
463
|
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
package/framework/CLAUDE.md
CHANGED
|
@@ -65,7 +65,8 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
65
65
|
| `fleet` | Visão agregada de toda feature ativa (raiz + worktrees) |
|
|
66
66
|
| `claim [f] [--release] [--list]` | Posse advisory — confirme antes de `finish`/`archive`/`delete` se outra sessão tiver claim vivo |
|
|
67
67
|
| `dag <f>` | DAG de `tasks.json`: sequencial/paralelo, recomenda isolamento |
|
|
68
|
-
| `verify <f> [task] [--json]` | Build+testes+validadores, antes do juiz LLM |
|
|
68
|
+
| `verify <f> [task] [--json]` | Build+testes+validadores+e2e, antes do juiz LLM |
|
|
69
|
+
| `e2e up\|status\|down <f>` | Stack de verificação (docker compose) |
|
|
69
70
|
| `doctor --worktrees [--prune]` | Reconcilia git × disco × features; só `--prune` remove algo |
|
|
70
71
|
| `report-check <f> [task] [--json]` | Audita relatório de sub-agent vs. trabalho entregue |
|
|
71
72
|
| `telemetry [--feature] [--limit]` | Caminho de execução da sessão a partir de `events.jsonl` |
|
package/framework/MORPH.md
CHANGED
|
@@ -148,7 +148,7 @@ Não invente caminhos alternativos.
|
|
|
148
148
|
|
|
149
149
|
- `spec.md` — especificação técnica: arquitetura, componentes, integrações, fluxos de dados, edge cases **técnicos**. Responde **como** os use cases serão realizados — não repete o comportamento. Deve bastar para implementar sem perguntas básicas: tecnologia listada sem explicar como os componentes se conectam é um spec.md fraco.
|
|
150
150
|
|
|
151
|
-
- `mandate.md` — contrato de implementação: stack exata, standards obrigatórios (com links), patterns proibidos, checklist de done verificável. Lido no início de cada task — específico o bastante para resolver ambiguidades sem consultar o Orchestrator.
|
|
151
|
+
- `mandate.md` — contrato de implementação: stack exata, standards obrigatórios (com links), patterns proibidos, **verificação de runtime** (como o stack sobe, que fluxos são exercitados, quais viram spec Playwright, setup de auth) e checklist de done verificável. Lido no início de cada task — específico o bastante para resolver ambiguidades sem consultar o Orchestrator.
|
|
152
152
|
|
|
153
153
|
- `tasks.json` — validada contra `framework/schemas/tasks.schema.json`. Cada task: `id` (T1, T2...), `title`, `description`, `dependencies` (IDs), `effort` (S/M/L/XL), `doneCriteria`, `status` (pending/in_progress/done/skipped/blocked), `outputs` (paths esperados), `usecase` (opcional — **ID** do use case, `UC-07`, nunca o nome do arquivo). `doneCriteria` de task com `usecase` carrega a **pós-condição literal**, com rótulo (`"P2 — cada veterinário traz..."`). Toda entrada do recap.md referencia um ID do tasks.json.
|
|
154
154
|
|
|
@@ -218,6 +218,8 @@ Gates são pontos de **pausa potencial**. Em `trust: "auto"` (default), auto-pas
|
|
|
218
218
|
| `designSystemDivergence` | true | Brainstorming detecta fontes conflitantes (spec antigo vs bundle local) |
|
|
219
219
|
| `standardsViolations` | array.length > 0 | Mandate ou implementation viola padrões proibidos |
|
|
220
220
|
| `evalScore` | < 9 | score composto da feature (via morph-review) abaixo do mínimo |
|
|
221
|
+
| `verificationStatus` | === 'fail' | `morph-spec verify` vermelho — build, testes, validadores **ou e2e** (a app não sobe / as specs não passam). Vale em **qualquer** gate |
|
|
222
|
+
| `verificationStale` | true | Stamp do `verify` com sha != HEAD: o verde não cobre mais o código. Só no **ship** (Gate 3) |
|
|
221
223
|
| `taskCount` | > 20 | Escopo grande — provavelmente vale revisar antes de implementar |
|
|
222
224
|
| `workType` | === 'hotfix' | Gate 3 pausa SEMPRE (detalhe abaixo, "Gates por tipo de trabalho") |
|
|
223
225
|
|
|
@@ -225,7 +227,9 @@ Gates são pontos de **pausa potencial**. Em `trust: "auto"` (default), auto-pas
|
|
|
225
227
|
| ------ | -------- | -------------------- |
|
|
226
228
|
| 1 — Proposta | `designSystemDivergence`, `standardsViolations` (improvável) | `morph-brainstorming` |
|
|
227
229
|
| 2 — Plano | Gate 1 + `taskCount`, `standardsViolations` em mandate | `morph-plan` |
|
|
228
|
-
| 3 — Review | `evalScore` composite, `standardsViolations` em implementation | `morph-review` |
|
|
230
|
+
| 3 — Review | `evalScore` composite, `standardsViolations` em implementation, `verificationStale` | `morph-review` |
|
|
231
|
+
|
|
232
|
+
`verificationStatus` não está numa linha só porque vale em todas — verify vermelho pausa qualquer gate.
|
|
229
233
|
|
|
230
234
|
**Gates por tipo de trabalho:** `chore`/`hotfix` pulam Gates 1 e 2 — carimbados por política no nascimento (`approvedBy: "policy:{tipo}"`, nunca `"auto"`, para não colidir com o gate-guard); a barra do morph-eval e o Gate 3 valem para todos os tipos. `hotfix`: Gate 3 **sempre humano** (mesmo em `trust: auto` — `morph-hotfix` pausa via `AskUserQuestion`, o `risk-detector` força, o backstop server-side recusa `approve ... review --mode auto`) e exige `4-review/review-report.md` em vez de `evaluator-report.md`. Demais tipos exigem `evaluator-report.md`, **enforced server-side** (`morph-spec approve {feature} review` recusa exit 1 sem ele; `--force` não dispensa) — ou `--self-assessed` registrado (`approvalGates.review.selfAssessed`, marcado no `morph-spec list`).
|
|
231
235
|
|
package/framework/agents.json
CHANGED
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"design", "ui", "ux", "design system", "tokens", "wcag",
|
|
103
103
|
"accessibility", "mockup", "flow", "shadcn", "tailwind"
|
|
104
104
|
],
|
|
105
|
-
"stack": ["
|
|
105
|
+
"stack": ["*"],
|
|
106
106
|
"standards": [
|
|
107
107
|
"frontend-design-system-naming",
|
|
108
108
|
"frontend-design-system-animations",
|
|
@@ -144,8 +144,9 @@
|
|
|
144
144
|
"stack": ["*"],
|
|
145
145
|
"standards": [],
|
|
146
146
|
"tools": "Read, Glob, Grep, Bash",
|
|
147
|
+
"mcp": ["playwright"],
|
|
147
148
|
"model": "opus",
|
|
148
|
-
"persona": "Você aplica as rubricas em .morph/framework/evals/rubrics/. Você não participou da implementação — avalia o mandate, o diff e a saída real dos testes, com contexto fresco. O orquestrador anexa o resultado do `morph-spec verify`, mas você tem Bash para conferir por conta própria: rode `morph-spec verify {feature} [task]`, testes/build do stack, `git log/diff/show` — nunca confie cegamente no que foi reportado. Bash é só para verificação: NUNCA para modificar código-fonte, tasks.json, feature.json ou o board de sessão — quem faz isso é o loop de correção, não você. Não reescreve código — produz scores 0-10 por dimensão (architecture, contracts, code-quality, test-coverage), feedback priorizado acionável, e ação clara (aprovado em >=9 / feedback para o loop de correção em <9). Silêncio em scores ≥ 9 é o sinal correto."
|
|
149
|
+
"persona": "Você aplica as rubricas em .morph/framework/evals/rubrics/. Você não participou da implementação — avalia o mandate, o diff e a saída real dos testes, com contexto fresco. O orquestrador anexa o resultado do `morph-spec verify`, mas você tem Bash para conferir por conta própria: rode `morph-spec verify {feature} [task]`, testes/build do stack, `git log/diff/show` — nunca confie cegamente no que foi reportado. Bash é só para verificação: NUNCA para modificar código-fonte, tasks.json, feature.json ou o board de sessão — quem faz isso é o loop de correção, não você. Você também tem Playwright: quando o nó `e2e` do verify subiu o stack, NAVEGUE a aplicação como um usuário faria e percorra os fluxos da feature — ler o diff prova que o código existe, usar a aplicação prova que ela funciona. Confira console e network atrás de erro que nenhum teste cobre. Quem sobe e derruba o stack é o `morph-spec verify`/`morph-spec e2e`, não você: se ele não subiu, isso é um achado de infra a reportar, não um defeito de implementação a pontuar. Se um fluxo que você exercitou merece virar regressão, recomende a spec Playwright no relatório — recomendar é seu papel, escrever não. Não reescreve código — produz scores 0-10 por dimensão (architecture, contracts, code-quality, test-coverage), feedback priorizado acionável, e ação clara (aprovado em >=9 / feedback para o loop de correção em <9). Silêncio em scores ≥ 9 é o sinal correto."
|
|
149
150
|
},
|
|
150
151
|
"scout": {
|
|
151
152
|
"title": "Codebase Scout",
|
package/framework/docs/CLI.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- **`morph-spec approve <feature> <gate> --mode auto`** — aprova um gate (`proposal`/`uiux`/`plan`/`review`) em `trust: auto`. **`uiux`** é o único gate cujo `AskUserQuestion` o hook `state-sync.js` não reconhece por texto (só casa "Gate 1/2/3") — aprove-o sempre explicitamente por este comando ou por `advance`, nunca contando com o hook. **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).
|
|
10
10
|
- **`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).
|
|
11
11
|
- **`morph-spec score <feature> <task> <score> [--dims a,c,q,t]`** — grava `taskScores` no feature.json com timestamp server-side; **nunca edite taskScores à mão**. Em PowerShell, cite o valor de `--dims` entre aspas (`--dims "9.5,9,9,9"`) — sem aspas, a lista pode chegar como tokens separados; o parser aceita ambas as formas, mas citar evita qualquer ambiguidade.
|
|
12
|
-
- **`morph-spec gate-check <feature> <gate> [--signals <json>] [--trust <mode>] [--json]`** — o caminho **default**: coleta do disco os sinais que `shouldPauseGate` (risk-detector.js) já sabe avaliar (`workType`, `taskCount`, verificação, sha/diffstat), decide a pausa e persiste `gateDecisions.{gate}`. `--signals` aceita só as chaves que o disco genuinamente não expõe sozinho — `designSystemDivergence`, `standardsViolations`, `evalScore` — qualquer outra chave é ignorada com aviso. No **review**, o `evalScore` real vem do token `**Composite-Score:** {n.n}` do `evaluator-report.md` (o avaliador o grava por contrato); sem o token, cai no proxy (média das `taskScores`); `--signals` vence ambos. Um stamp de `verification` com status `fail` (último `morph-spec verify` vermelho) **força pausa** em qualquer gate.
|
|
12
|
+
- **`morph-spec gate-check <feature> <gate> [--signals <json>] [--trust <mode>] [--json]`** — o caminho **default**: coleta do disco os sinais que `shouldPauseGate` (risk-detector.js) já sabe avaliar (`workType`, `taskCount`, verificação, sha/diffstat), decide a pausa e persiste `gateDecisions.{gate}`. `--signals` aceita só as chaves que o disco genuinamente não expõe sozinho — `designSystemDivergence`, `standardsViolations`, `evalScore` — qualquer outra chave é ignorada com aviso. No **review**, o `evalScore` real vem do token `**Composite-Score:** {n.n}` do `evaluator-report.md` (o avaliador o grava por contrato); sem o token, cai no proxy (média das `taskScores`); `--signals` vence ambos. Um stamp de `verification` com status `fail` (último `morph-spec verify` vermelho) **força pausa** em qualquer gate. Um stamp `pass` mas **stale** (sha diferente do HEAD atual — código mudou depois do último verify) força pausa **só no ship** (`review`); em `plan`/`uiux`/`proposal` a staleness é computada e reportada em `signals`, mas não pausa nada — é regra de ship, não de gate genérico. Stamp **ausente** nunca pausa por isso (features legadas sem stamp).
|
|
13
13
|
- **`morph-spec gate-decision <feature> <gate> --pause <bool> [--reason <txt>] [--signals <json>]`** — o **escape manual**: registra uma decisão já tomada pela sessão (ex.: `discoveryMode: "interview"`, sinal que `shouldPauseGate` nem representa). Mesmo write path do `gate-check` — grava `gateDecisions` no feature.json com timestamp server-side (shape em `framework/schemas/feature.schema.json`); **nunca edite gateDecisions à mão** — o Edit manual colide com o hook state-sync.
|
|
14
14
|
|
|
15
15
|
## Features
|
|
@@ -31,11 +31,12 @@
|
|
|
31
31
|
## Validação
|
|
32
32
|
|
|
33
33
|
- **`morph-spec dag <feature>`** — análise do DAG de tasks.json com decisão sequencial vs paralelo **ponderada por effort** (raízes S vão inline, nunca ganham agente próprio). Também cruza `tasks.json` com os use cases: `usecase` apontando para um `UC-{nn}` inexistente é **erro** (exit 1, não despacha); pós-condição órfã e `doneCriteria` que parafraseou em vez de copiar são avisos. `--mermaid` nunca é bloqueado.
|
|
34
|
-
- **`morph-spec verify <feature> [task] [--json]`** — cadeia determinística build+testes+validadores (código antes do juiz LLM); escopo de task quando informado, senão a feature inteira.
|
|
34
|
+
- **`morph-spec verify <feature> [task] [--json] [--skip-e2e] [--fresh-e2e]`** — cadeia determinística build+testes+validadores+**e2e** (código antes do juiz LLM); escopo de task quando informado, senão a feature inteira. O nó `e2e` é o único que **executa** a aplicação: sobe o `docker compose` do projeto, espera todo serviço ficar saudável e roda as specs Playwright quando existem (sem specs, ainda vale como smoke — "a app sobe" já pega o serviço não registrado que derruba o startup e passa batido no build). Aplicabilidade: escopo de feature (Gate 3) **sempre** roda; escopo de task roda para `group` `frontend`/`backend`/`infra` e dispensa `tests`/`docs`. **Dispensa legítima × setup faltando:** projeto sem compose e sem a integração `docker` é `skip` com razão; projeto que **declara** `docker` e não tem compose é `fail` — é verificação que nunca foi montada, não projeto que não containeriza.
|
|
35
|
+
- **`morph-spec e2e up|status|down <feature>`** — ciclo de vida do stack de verificação, compartilhando toda a lógica com o nó `e2e` do verify. `up [--fresh]` reusa um stack quente **só** quando ele provadamente serve o código atual: o carimbo compara `HEAD` + `git status --porcelain` + `git diff HEAD`, então qualquer edição, commitada ou não, invalida o reuso (a exceção é o serviço com bind mount do código, que serve o disco por construção). Na dúvida, rebuild — o layer cache do Docker faz disso barato. `status [--json]` responde a pergunta que decide se um verde vale alguma coisa (no ar? saudável? construído do código atual?) e sai 0 só com os três; o `baseUrl` do payload é o endereço real da app — é dele que a `morph-review` tira a URL que vai no prompt do avaliador, em vez de deixá-lo adivinhar `localhost:3000`. `down [--volumes]` é best-effort e nunca bloqueia. Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os containers e o bloco de portas do fleet (`env-allocator`) separa as portas — um compose com porta fixa não pode ser isolado, e isso é avisado. O bloco vale nos **dois** caminhos (este comando e o nó do `verify`): `PORT`, `MORPH_PORT_BASE`, `ASPNETCORE_URLS` em dotnet, mais `PLAYWRIGHT_BASE_URL` apontando para a porta alocada. Por isso o `playwright.config.ts` precisa ler a base URL do ambiente em vez de cravá-la — com a URL cravada, a suíte bate numa porta vazia e o `ECONNREFUSED` se disfarça de app quebrada.
|
|
35
36
|
- **`morph-spec doctor --worktrees [--prune] [--json]`** — reconcilia o registro do git × as pastas em disco × as features conhecidas. Worktrees órfãos acumulavam **invisíveis**: o `fleet` só reporta worktrees em branch `morph/*`, então um worktree em outra branch (resíduo de finish anterior) não aparecia em lugar nenhum. `--prune` roda o único reparo seguro (`git worktree prune` — registros de pastas que já sumiram); apagar pasta que ainda existe é reportado com o comando exato, nunca executado.
|
|
36
37
|
- **`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.
|
|
37
38
|
|
|
38
39
|
## Observabilidade
|
|
39
40
|
|
|
40
41
|
- **`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.
|
|
41
|
-
- **`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
|
|
42
|
+
- **`morph-spec cost [feature] [--by task|phase] [--all-trees] [--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 eram observáveis por hook até o evento `SessionUsage` — quando presente no stream, `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheCreationTokens` são somados e exibidos à parte como "tokens reais", nunca misturados com a proxy de bytes). `--all-trees` agrega `.morph/logs/events.jsonl` de **toda árvore git** que tiver um (não só a corrente) — reusa a mesma enumeração de worktree do `doctor --worktrees`/`fleet`; mostra um resumo por árvore + o total combinado (`--by` aplica ao agregado combinado). Use antes/depois de mudar skill ou fluxo para medir o efeito.
|
|
@@ -102,8 +102,29 @@ export async function run(payload) {
|
|
|
102
102
|
// advances once that file exists) — the deadlock this hook used to create.
|
|
103
103
|
const entryGate = PHASE_DIR_ENTRY_GATE[targetDir];
|
|
104
104
|
if (entryGate && isGateApproved(featureName, entryGate, root)) {
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
// uiux is OPTIONAL — proposal approval alone is enough to enter
|
|
106
|
+
// 2-plan/ for a feature that never took the design branch (no
|
|
107
|
+
// 1-design/ dir), exactly as before this check. But once a feature
|
|
108
|
+
// HAS opted into uiux (1-design/ exists), that gate is not
|
|
109
|
+
// decorative: 2-plan/ must wait for it too, or a feature can skip
|
|
110
|
+
// straight from an approved proposal into planning with its design
|
|
111
|
+
// gate silently left pending.
|
|
112
|
+
const tookDesignBranch = targetDir === '2-plan'
|
|
113
|
+
&& existsSync(resolveTargetPath(`.morph/features/${featureName}/1-design`, root));
|
|
114
|
+
|
|
115
|
+
if (!tookDesignBranch || isGateApproved(featureName, 'uiux', root)) {
|
|
116
|
+
logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
logHookActivity('enforce-phase-writes', 'PreToolUse', 'blocked');
|
|
121
|
+
return blockResult(
|
|
122
|
+
`MORPH-SPEC: Writing to '2-plan/' is not allowed yet.\n` +
|
|
123
|
+
`This feature entered the uiux branch (1-design/ exists) and the 'uiux' gate is still pending.\n\n` +
|
|
124
|
+
`Approve the design gate first, then re-enter plan:\n` +
|
|
125
|
+
` morph-spec approve ${featureName} uiux\n` +
|
|
126
|
+
` morph-spec advance ${featureName}`
|
|
127
|
+
);
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
const phaseLabel = phase.toUpperCase();
|
|
@@ -94,6 +94,11 @@ function isApprovalAnswer(answer) {
|
|
|
94
94
|
|
|
95
95
|
function gateFromQuestion(question) {
|
|
96
96
|
if (typeof question !== 'string') return null;
|
|
97
|
+
// Checked BEFORE the generic patterns below: the pinned uiux template is
|
|
98
|
+
// "Gate uiux — Design de {feature} aprovado?" (morph-uiux/SKILL.md) — it
|
|
99
|
+
// must never fall through to 'plan' via a stray "plan"/"plano" substring in
|
|
100
|
+
// the feature name. `/gate\s+uiux/i` alone is unambiguous either way.
|
|
101
|
+
if (/gate\s+uiux/i.test(question)) return 'uiux';
|
|
97
102
|
const lower = question.toLowerCase();
|
|
98
103
|
if (lower.includes('gate 1') || lower.includes('proposta')) return 'proposal';
|
|
99
104
|
if (lower.includes('gate 2') || lower.includes('plano') || lower.includes('plan')) return 'plan';
|
|
@@ -41,6 +41,32 @@ function normalize(p) {
|
|
|
41
41
|
return (p || '').replace(/\\/g, '/');
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// events.jsonl grows forever across a project's lifetime (R2 rotation caps
|
|
45
|
+
// the FILE, not any single line) — a multi-KB sub-agent prompt or a long
|
|
46
|
+
// Bash command written verbatim on every dispatch/call is the dominant per-
|
|
47
|
+
// line cost. Capped AT THE WRITE SITE (not in the shared logger, so every
|
|
48
|
+
// OTHER event type stays exactly as-is) to `MAX_FIELD_CHARS`, with the
|
|
49
|
+
// ORIGINAL length preserved in a sibling `<field>Chars` key — `morph-spec
|
|
50
|
+
// cost` sums prompt chars for its dispatch-cost view, and must prefer that
|
|
51
|
+
// field over `prompt.length` once it's present (a truncated string's own
|
|
52
|
+
// length would silently undercount every dispatch over the cap). This shape
|
|
53
|
+
// is a CONTRACT with the telemetry reader — do not rename either half.
|
|
54
|
+
const MAX_FIELD_CHARS = 2000;
|
|
55
|
+
const TRUNCATE_SUFFIX = '…[truncado]';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cap a free-text field (prompt/command) at MAX_FIELD_CHARS, always
|
|
59
|
+
* reporting the field's true original length alongside it.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} value
|
|
62
|
+
* @returns {{ value: string, chars: number }}
|
|
63
|
+
*/
|
|
64
|
+
function capField(value) {
|
|
65
|
+
const str = typeof value === 'string' ? value : '';
|
|
66
|
+
if (str.length <= MAX_FIELD_CHARS) return { value: str, chars: str.length };
|
|
67
|
+
return { value: str.slice(0, MAX_FIELD_CHARS) + TRUNCATE_SUFFIX, chars: str.length };
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
/**
|
|
45
71
|
* Classify a Read target into the telemetry `kind` taxonomy — a contract with
|
|
46
72
|
* the observability side (src/lib/telemetry/telemetry-reader.js), so these
|
|
@@ -144,7 +170,8 @@ export async function run(payload) {
|
|
|
144
170
|
|
|
145
171
|
// UserPromptSubmit carries the user's prompt and no tool_name.
|
|
146
172
|
if (payload.hook_event_name === 'UserPromptSubmit' || (!payload.tool_name && typeof payload.prompt === 'string')) {
|
|
147
|
-
|
|
173
|
+
const userPrompt = capField(payload.prompt || '');
|
|
174
|
+
event = { event: 'UserPromptSubmit', tool: 'UserPrompt', prompt: userPrompt.value, promptChars: userPrompt.chars };
|
|
148
175
|
} else {
|
|
149
176
|
const tool = payload.tool_name;
|
|
150
177
|
if (!tool) return null;
|
|
@@ -156,7 +183,9 @@ export async function run(payload) {
|
|
|
156
183
|
} else if (tool === 'Agent' || tool === 'Task') {
|
|
157
184
|
event.persona = input.subagent_type || '';
|
|
158
185
|
if (input.description) event.desc = input.description;
|
|
159
|
-
|
|
186
|
+
const dispatchPrompt = capField(input.prompt || '');
|
|
187
|
+
event.prompt = dispatchPrompt.value;
|
|
188
|
+
event.promptChars = dispatchPrompt.chars;
|
|
160
189
|
} else if (tool === 'Read') {
|
|
161
190
|
event.target = targetFilePath || normalize(input.file_path || input.path);
|
|
162
191
|
event.kind = classifyReadKind(event.target);
|
|
@@ -169,7 +198,9 @@ export async function run(payload) {
|
|
|
169
198
|
// omit bytes
|
|
170
199
|
}
|
|
171
200
|
} else if (tool === 'Bash') {
|
|
172
|
-
|
|
201
|
+
const bashCommand = capField(input.command || '');
|
|
202
|
+
event.command = bashCommand.value;
|
|
203
|
+
event.commandChars = bashCommand.chars;
|
|
173
204
|
} else if (tool === 'Write' || tool === 'Edit') {
|
|
174
205
|
event.target = normalize(input.file_path || input.path);
|
|
175
206
|
} else {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* Fail-open: exits 0 on any error.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, statSync } from 'fs';
|
|
25
25
|
import { join } from 'path';
|
|
26
26
|
import { createHash } from 'crypto';
|
|
27
27
|
import { runBuildCheck } from '../../shared/build-check.js';
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from '../../shared/state-reader.js';
|
|
31
31
|
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
32
32
|
import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
|
|
33
|
+
import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Audit recap.md against tasks.json + standard-citation rule.
|
|
@@ -121,6 +122,80 @@ function auditRecapAndTasks(featurePath) {
|
|
|
121
122
|
return warnings;
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
// Safety valve for a pathological outlier transcript — NOT needed for the
|
|
126
|
+
// normal case: a real 45 MB / ~16k-line transcript (inspected directly while
|
|
127
|
+
// building this) parses+sums in ~350ms locally, well inside a Stop hook's
|
|
128
|
+
// budget. Above this, skip rather than risk an unbounded read.
|
|
129
|
+
const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Sum real token usage from a Claude Code session transcript (JSONL, the
|
|
133
|
+
* `transcript_path` the Stop payload carries).
|
|
134
|
+
*
|
|
135
|
+
* Each assistant TURN is written to the transcript as MULTIPLE JSONL lines —
|
|
136
|
+
* one per content block (thinking/text/tool_use) — every one of them
|
|
137
|
+
* repeating the exact SAME turn-level `message.usage` object under the same
|
|
138
|
+
* `message.id`. Verified against a real transcript: 5982 assistant lines but
|
|
139
|
+
* only 3315 unique message ids, with byte-identical usage across duplicates
|
|
140
|
+
* of the same id — summing every line naively double/triple-counts (measured
|
|
141
|
+
* ~2x on output_tokens on that file). This is why a per-line sum is wrong and
|
|
142
|
+
* a dedup-by-id sum is required; there is no running/cumulative total field
|
|
143
|
+
* to lean on instead — each turn's `usage` is that turn's own cost.
|
|
144
|
+
*
|
|
145
|
+
* Tolerant to shape: any missing usage field reads as 0. A corrupt/truncated
|
|
146
|
+
* line is skipped, not fatal — same discipline as telemetry-logger's reader.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} [transcriptPath]
|
|
149
|
+
* @returns {{inputTokens:number,outputTokens:number,cacheReadTokens:number,cacheCreationTokens:number}|null}
|
|
150
|
+
* null when there is nothing to report (no path, missing/oversized/
|
|
151
|
+
* unreadable file, or zero assistant messages carrying usage).
|
|
152
|
+
*/
|
|
153
|
+
function computeSessionUsage(transcriptPath) {
|
|
154
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') return null;
|
|
155
|
+
try {
|
|
156
|
+
if (!existsSync(transcriptPath)) return null;
|
|
157
|
+
if (statSync(transcriptPath).size > MAX_TRANSCRIPT_BYTES) return null;
|
|
158
|
+
|
|
159
|
+
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
160
|
+
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheCreationTokens = 0;
|
|
161
|
+
const seenMessageIds = new Set();
|
|
162
|
+
let sawAny = false;
|
|
163
|
+
|
|
164
|
+
for (const line of raw.split('\n')) {
|
|
165
|
+
const trimmed = line.trim();
|
|
166
|
+
if (!trimmed) continue;
|
|
167
|
+
let entry;
|
|
168
|
+
try {
|
|
169
|
+
entry = JSON.parse(trimmed);
|
|
170
|
+
} catch {
|
|
171
|
+
continue; // one corrupt/truncated line must not lose the whole transcript
|
|
172
|
+
}
|
|
173
|
+
if (entry?.type !== 'assistant') continue;
|
|
174
|
+
const usage = entry?.message?.usage;
|
|
175
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
176
|
+
|
|
177
|
+
// Dedup by message.id (fallback to the line's own uuid when a message
|
|
178
|
+
// carries none) — first occurrence wins, duplicates carry identical
|
|
179
|
+
// values so which one wins never matters.
|
|
180
|
+
const dedupKey = entry?.message?.id || entry?.uuid;
|
|
181
|
+
if (dedupKey) {
|
|
182
|
+
if (seenMessageIds.has(dedupKey)) continue;
|
|
183
|
+
seenMessageIds.add(dedupKey);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
sawAny = true;
|
|
187
|
+
inputTokens += Number(usage.input_tokens) || 0;
|
|
188
|
+
outputTokens += Number(usage.output_tokens) || 0;
|
|
189
|
+
cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
|
|
190
|
+
cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return sawAny ? { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens } : null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null; // fail-open
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
124
199
|
// Phases where compaction is strongly recommended before starting
|
|
125
200
|
const COMPACT_TRIGGER_PHASES = new Set(['implement']);
|
|
126
201
|
|
|
@@ -128,12 +203,15 @@ const COMPACT_TRIGGER_PHASES = new Set(['implement']);
|
|
|
128
203
|
const HEAVY_SOURCE_PHASES = new Set(['plan', 'uiux']);
|
|
129
204
|
|
|
130
205
|
/**
|
|
131
|
-
* @param {object}
|
|
132
|
-
* reads its own state from disk
|
|
206
|
+
* @param {object} payload - parsed Stop stdin payload. Most of this hook's
|
|
207
|
+
* logic reads its own state from disk rather than the payload, EXCEPT the
|
|
208
|
+
* session-usage capture below, which needs `payload.transcript_path` (and
|
|
209
|
+
* `payload.session_id` for the throttle key) — the one thing only the
|
|
210
|
+
* payload carries.
|
|
133
211
|
* @returns {Promise<object|null>} a hookSpecificOutput advisory (phase
|
|
134
212
|
* transition / build failure / recap drift), or null (pass/silent).
|
|
135
213
|
*/
|
|
136
|
-
export async function run(
|
|
214
|
+
export async function run(payload) {
|
|
137
215
|
try {
|
|
138
216
|
// Prevent infinite loop
|
|
139
217
|
if (process.env.MORPH_STOP_HOOK_ACTIVE === '1') return null;
|
|
@@ -264,6 +342,40 @@ export async function run(_payload) {
|
|
|
264
342
|
}
|
|
265
343
|
// ────────────────────────────────────────────────────────────────────────
|
|
266
344
|
|
|
345
|
+
// ── Session usage (real tokens, from the transcript) ──────────────────────
|
|
346
|
+
// Silent side effect — never contributes to `warnings`/additionalContext,
|
|
347
|
+
// same as telemetry-log.js's capture. Throttled: only re-emitted when the
|
|
348
|
+
// computed totals actually changed since the last SessionUsage recorded
|
|
349
|
+
// for this session_id, so an unchanged transcript across consecutive
|
|
350
|
+
// Stops (no new assistant turn) does not re-append an identical event.
|
|
351
|
+
try {
|
|
352
|
+
const usage = computeSessionUsage(payload?.transcript_path);
|
|
353
|
+
if (usage) {
|
|
354
|
+
const usagePath = join(memDir, 'session-usage.json');
|
|
355
|
+
let stored = {};
|
|
356
|
+
try {
|
|
357
|
+
if (existsSync(usagePath)) stored = JSON.parse(readFileSync(usagePath, 'utf-8'));
|
|
358
|
+
} catch { /* corrupt file — start fresh */ }
|
|
359
|
+
|
|
360
|
+
const sessionKey = payload?.session_id || 'default';
|
|
361
|
+
const last = stored[sessionKey];
|
|
362
|
+
const unchanged = last
|
|
363
|
+
&& last.inputTokens === usage.inputTokens
|
|
364
|
+
&& last.outputTokens === usage.outputTokens
|
|
365
|
+
&& last.cacheReadTokens === usage.cacheReadTokens
|
|
366
|
+
&& last.cacheCreationTokens === usage.cacheCreationTokens;
|
|
367
|
+
|
|
368
|
+
if (!unchanged) {
|
|
369
|
+
appendTelemetryEvent({ event: 'SessionUsage', ...usage }, cwd);
|
|
370
|
+
stored[sessionKey] = usage;
|
|
371
|
+
try { writeFileSync(usagePath, JSON.stringify(stored, null, 2), 'utf-8'); } catch { /* non-blocking */ }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
// Fail-open — usage capture must never block Stop
|
|
376
|
+
}
|
|
377
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
267
379
|
if (warnings.length === 0) return null;
|
|
268
380
|
|
|
269
381
|
const message = [
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
* Also the real call site for activity-log rotation (R1.2 of the performance
|
|
28
28
|
* plan): SessionStart is the only reliable "between sessions" checkpoint the
|
|
29
29
|
* harness gets, so this is where `.morph/logs/activity.jsonl` gets archived
|
|
30
|
-
* to `.morph/logs/sessions/` once it crosses the size threshold.
|
|
30
|
+
* to `.morph/logs/sessions/` once it crosses the size threshold. The same
|
|
31
|
+
* checkpoint now also rotates `.morph/logs/events.jsonl` (telemetry-logger.js)
|
|
32
|
+
* once IT crosses its own threshold — events.jsonl grew unbounded even after
|
|
33
|
+
* R1.2, since that rotation only ever covered activity.jsonl.
|
|
31
34
|
*
|
|
32
35
|
* Fail-open: any error, or nothing to report, → pass() silently. This hook
|
|
33
36
|
* never blocks a session from starting.
|
|
@@ -46,6 +49,7 @@ import {
|
|
|
46
49
|
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
47
50
|
import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registry.js';
|
|
48
51
|
import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
|
|
52
|
+
import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
|
|
49
53
|
|
|
50
54
|
function projectDir() {
|
|
51
55
|
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
@@ -223,10 +227,11 @@ export async function run(payload) {
|
|
|
223
227
|
return null;
|
|
224
228
|
}
|
|
225
229
|
|
|
226
|
-
// Rotate the activity
|
|
227
|
-
// Best-effort, never fatal to session start —
|
|
228
|
-
//
|
|
230
|
+
// Rotate the activity + events logs before anything else touches them
|
|
231
|
+
// this session. Best-effort, never fatal to session start — both rotate*
|
|
232
|
+
// helpers are themselves fail-open (return false on any error).
|
|
229
233
|
try { rotateActivityLogIfLarge(dir); } catch { /* non-blocking */ }
|
|
234
|
+
try { rotateEventsLogIfLarge(dir); } catch { /* non-blocking */ }
|
|
230
235
|
|
|
231
236
|
if (isSecondaryWorktree(dir)) {
|
|
232
237
|
const feature = featureFromBranch(dir) || featureFromSingleDir(dir);
|
|
Binary file
|
|
@@ -16,12 +16,22 @@
|
|
|
16
16
|
* - Windows-safe paths via path.join
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
appendFileSync, readFileSync, mkdirSync, existsSync, statSync, renameSync,
|
|
21
|
+
} from 'fs';
|
|
20
22
|
import { join, dirname } from 'path';
|
|
21
23
|
|
|
22
24
|
const LOGS_DIR = '.morph/logs';
|
|
23
25
|
export const EVENTS_FILE = join(LOGS_DIR, 'events.jsonl');
|
|
24
26
|
|
|
27
|
+
// SessionStart (worktree-context.js) rotates events.jsonl once it crosses
|
|
28
|
+
// this size, alongside the pre-existing activity.jsonl rotation — same
|
|
29
|
+
// call site, same "between sessions" checkpoint. events.jsonl has no natural
|
|
30
|
+
// per-turn cap (prompts/commands are truncated at the write site, but the
|
|
31
|
+
// LINE COUNT itself grows unbounded across a long-lived project), so without
|
|
32
|
+
// this it grows forever the way activity.jsonl used to before R1.2.
|
|
33
|
+
export const ROTATE_THRESHOLD_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
34
|
+
|
|
25
35
|
function getEventsPath(projectPath) {
|
|
26
36
|
return join(projectPath || process.cwd(), EVENTS_FILE);
|
|
27
37
|
}
|
|
@@ -73,3 +83,56 @@ export function readTelemetryEvents(projectPath) {
|
|
|
73
83
|
return [];
|
|
74
84
|
}
|
|
75
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Archive the current events.jsonl to `.morph/logs/sessions/`, mirroring
|
|
89
|
+
* activity-logger.js's `archiveSession()` — same directory, same rename-not-
|
|
90
|
+
* rewrite mechanics (O(1) even at multi-MB sizes). Named with an `events-`
|
|
91
|
+
* prefix (activity-logger's archives carry no prefix) so the two streams
|
|
92
|
+
* never collide inside the shared `sessions/` directory and stay
|
|
93
|
+
* distinguishable at a glance.
|
|
94
|
+
*
|
|
95
|
+
* Fail-silent — called only from `rotateEventsLogIfLarge` below, which has
|
|
96
|
+
* already confirmed the file exists.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} [projectPath]
|
|
99
|
+
*/
|
|
100
|
+
function archiveEvents(projectPath) {
|
|
101
|
+
try {
|
|
102
|
+
const eventsPath = getEventsPath(projectPath);
|
|
103
|
+
if (!existsSync(eventsPath)) return;
|
|
104
|
+
|
|
105
|
+
const sessionsDir = join(projectPath || process.cwd(), LOGS_DIR, 'sessions');
|
|
106
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
107
|
+
|
|
108
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
109
|
+
const archivePath = join(sessionsDir, `events-${stamp}.jsonl`);
|
|
110
|
+
renameSync(eventsPath, archivePath);
|
|
111
|
+
} catch {
|
|
112
|
+
// Fail-silent
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Rotate events.jsonl when it crosses `thresholdBytes`. Called from
|
|
118
|
+
* worktree-context.js on SessionStart, right next to the existing
|
|
119
|
+
* `rotateActivityLogIfLarge()` call — the only reliable "between sessions"
|
|
120
|
+
* checkpoint the harness gets. `thresholdBytes` is injectable so a test can
|
|
121
|
+
* force rotation without writing multiple megabytes.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} [projectPath] - Project root (defaults to cwd)
|
|
124
|
+
* @param {number} [thresholdBytes] - Rotate once the log is at least this large
|
|
125
|
+
* @returns {boolean} true if a rotation was performed
|
|
126
|
+
*/
|
|
127
|
+
export function rotateEventsLogIfLarge(projectPath, thresholdBytes = ROTATE_THRESHOLD_BYTES) {
|
|
128
|
+
try {
|
|
129
|
+
const eventsPath = getEventsPath(projectPath);
|
|
130
|
+
if (!existsSync(eventsPath)) return false;
|
|
131
|
+
const { size } = statSync(eventsPath);
|
|
132
|
+
if (size < thresholdBytes) return false;
|
|
133
|
+
archiveEvents(projectPath);
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -106,7 +106,8 @@
|
|
|
106
106
|
"properties": {
|
|
107
107
|
"build": { "type": "string", "enum": ["pass", "fail", "skip"] },
|
|
108
108
|
"tests": { "type": "string", "enum": ["pass", "fail", "skip"] },
|
|
109
|
-
"validators": { "type": "string", "enum": ["pass", "fail", "skip"] }
|
|
109
|
+
"validators": { "type": "string", "enum": ["pass", "fail", "skip"] },
|
|
110
|
+
"e2e": { "type": "string", "enum": ["pass", "fail", "skip"], "description": "Docker bring-up + Playwright specs against the running stack. Absent on entries written before the node existed — which is why it is NOT in `required`." }
|
|
110
111
|
},
|
|
111
112
|
"additionalProperties": false
|
|
112
113
|
},
|
|
@@ -132,7 +133,8 @@
|
|
|
132
133
|
"properties": {
|
|
133
134
|
"build": { "type": "string", "enum": ["pass", "fail", "skip"] },
|
|
134
135
|
"tests": { "type": "string", "enum": ["pass", "fail", "skip"] },
|
|
135
|
-
"validators": { "type": "string", "enum": ["pass", "fail", "skip"] }
|
|
136
|
+
"validators": { "type": "string", "enum": ["pass", "fail", "skip"] },
|
|
137
|
+
"e2e": { "type": "string", "enum": ["pass", "fail", "skip"], "description": "Docker bring-up + Playwright specs against the running stack. Absent on entries written before the node existed — which is why it is NOT in `required`." }
|
|
136
138
|
},
|
|
137
139
|
"additionalProperties": false
|
|
138
140
|
},
|
|
@@ -147,7 +149,7 @@
|
|
|
147
149
|
},
|
|
148
150
|
"verification": {
|
|
149
151
|
"type": "object",
|
|
150
|
-
"description": "The single latest deterministic verify stamp (any scope), written ONLY by `morph-spec verify`. Distinct from `verifications`/`taskVerifications` above (per-scope history with attempts/nodes): this is overwritten by every verify call regardless of scope, carrying just enough to correlate a pass/fail with the exact commit it covers. Primary consumer: `morph-spec gate-check`, which
|
|
152
|
+
"description": "The single latest deterministic verify stamp (any scope), written ONLY by `morph-spec verify`. Distinct from `verifications`/`taskVerifications` above (per-scope history with attempts/nodes): this is overwritten by every verify call regardless of scope, carrying just enough to correlate a pass/fail with the exact commit it covers. Primary consumer: `morph-spec gate-check`, which feeds `status` to shouldPauseGate as `verificationStatus` — a red verify pauses the gate (risk-detector.js). The remaining fields (sha/ts/scope, and the derived `verificationStale`) are audit context, not pause rules.",
|
|
151
153
|
"required": ["status", "sha", "ts", "scope"],
|
|
152
154
|
"properties": {
|
|
153
155
|
"status": { "type": "string", "enum": ["pass", "fail"] },
|