@polymorphism-tech/morph-spec 8.12.0 → 8.15.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.
Files changed (32) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +2 -2
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -2
  7. package/framework/MORPH.md +1 -1
  8. package/framework/agents.json +2 -2
  9. package/framework/hooks/claude-code/core/enforce-phase-writes.js +21 -9
  10. package/framework/hooks/claude-code/core/gate-guard.js +68 -35
  11. package/framework/hooks/claude-code/core/protect-spec-files.js +11 -6
  12. package/framework/hooks/claude-code/core/state-sync.js +66 -26
  13. package/framework/hooks/claude-code/core/trace-autogen.js +9 -4
  14. package/framework/hooks/claude-code/core/worktree-context.js +12 -3
  15. package/framework/hooks/shared/feature-locator.js +164 -0
  16. package/framework/hooks/shared/payload-utils.js +71 -0
  17. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  18. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  19. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  20. package/framework/skills/core/morph-brainstorming/SKILL.md +2 -2
  21. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  22. package/framework/skills/core/morph-hotfix/SKILL.md +9 -3
  23. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  24. package/framework/skills/core/morph-plan/SKILL.md +2 -2
  25. package/framework/skills/core/morph-proposal/SKILL.md +2 -2
  26. package/framework/skills/core/morph-review/SKILL.md +10 -8
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/package.json +1 -1
  30. package/src/commands/project/claim.js +76 -7
  31. package/src/commands/state/advance.js +43 -0
  32. package/src/utils/hooks-installer.js +10 -2
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.12.0
6
+ **Version:** 8.15.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.12.0 by [Polymorphism Tech](https://polymorphism.tech)*
371
+ *morph-spec v8.15.0 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -374,9 +374,9 @@ program
374
374
  // ─── Claims (advisory feature ownership) ──────────────────────────────────
375
375
  program
376
376
  .command('claim [feature]')
377
- .description('Show which session holds each feature (claims are advisory warnings, never locks). --release frees an orphan claim left by a dead session')
377
+ .description('claim [feature] acquires it (moves this tree\'s previous claim in the same step); no args shows the board (claims are advisory warnings, never locks)')
378
378
  .option('--release', 'Release the claim on <feature> (recovery: the owning session died)')
379
- .option('--list', 'List every claim on record (the default)')
379
+ .option('--list', 'Read-only: show claims without acquiring (default when no feature is given)')
380
380
  .option('--json', 'Output as JSON')
381
381
  .action((feature, options) => claimCommand(feature, options));
382
382
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "8.12.0",
3
+ "version": "8.15.0",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -185,4 +185,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
185
185
 
186
186
  ---
187
187
 
188
- *morph-spec v8.12.0 by Polymorphism Tech*
188
+ *morph-spec v8.15.0 by Polymorphism Tech*
@@ -249,4 +249,4 @@ morph-spec doctor
249
249
 
250
250
  ---
251
251
 
252
- *morph-spec v8.12.0 by Polymorphism Tech*
252
+ *morph-spec v8.15.0 by Polymorphism Tech*
@@ -26,7 +26,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
26
26
  - Auto-advance entre tarefas durante a fase implement — não pause sem ambiguidade bloqueante.
27
27
  - Abrir toda feature `feature`/`bug` com a escolha de **modo de descoberta** (entrevista ou automático), depois de varrer o ambiente e montar a árvore de decisões abertas. **No modo entrevista, uma pergunta por chamada de `AskUserQuestion`** — o "1-4 perguntas por chamada" do bloco NUNCA é o teto da tool, não licença para agrupar a entrevista. O Gate 1 pausa sempre nesse modo.
28
28
  - Atualizar o progresso em `tasks.json` ao concluir cada task gravando apenas os campos mutáveis `status`, `outputs` e `notes` — via `Write` OU `Edit` (o hook `protect-spec-files` faz diff semântico e permite qualquer um dos dois desde que o contrato do plano permaneça congelado).
29
- - Apresentar gates com o template fixo definido em MORPH.md / morph-plan.
29
+ - Apresentar gates com o template fixo definido em MORPH.md / morph-plan. **A pergunta PRECISA nomear a feature** ("Gate 2 — Plano de {feature} aprovado?"): é por esse nome que o hook `state-sync` atribui a aprovação ao `feature.json` certo, na árvore certa. Sem nome, e havendo mais de uma feature na árvore, ele **recusa a gravar** e avisa — adivinhar já carimbou um Gate 2 na feature errada. Nesse caso aprove com `morph-spec approve <feature> <gate>`.
30
30
  - Registrar decisões não triviais em `decisions.md`.
31
31
  - Aplicar `morph-eval` após cada tarefa; silêncio em score ≥ 9 é o sinal correto. Score < 9 dispara o loop de correção autônomo (re-implementa, sem pausa), não escala ao humano entre tarefas. No Gate 3, o score composto da feature vem do **avaliador independente** despachado pela `morph-review` (persona `evaluator`, contexto fresco) — nunca de auto-avaliação de quem implementou.
32
32
  - Incluir as 3 cláusulas do contrato do relatório final em todo dispatch de sub-agent (**o sub-agent GRAVA o relatório em arquivo canônico ANTES de encerrar — `.morph/features/{f}/3-implement/reports/{taskId}.md`, ou `4-review/evaluator-report.md` para o avaliador — com os 4 itens obrigatórios (o quê / arquivos / problemas / auto-score 0-10); esse arquivo é o valor de retorno autoritativo, a última mensagem de texto é cortesia best-effort e pode não chegar inteira; ainda assim não chamar `SendMessage`, que gera um evento a mais e custa um turno inútil**; não tocar tasks.json/feature.json/board; sem build/test da suíte compartilhada em batch paralelo) — templates em `morph-implement` §4 e `morph-apply` §3b. **O orquestrador lê o arquivo ao retomar controle** — nunca confia só na mensagem de retorno da tool.
@@ -61,7 +61,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
61
61
  - `morph-spec handoff <feature>` — regenera o documento de retomada (`.morph/features/{f}/handoff.md`): comprimido, derivado, aponta para os artefatos em vez de copiá-los. Gerado automaticamente em `setup`/`create --worktree`/`advance`.
62
62
  - `morph-spec fleet` — visão da frota: toda feature ativa (raiz + worktrees) com fase, gates pendentes, tasks, **remoto** (`local`/`pushed`/`PR #N`), **claim** (que sessão está na feature), portas, handoff e atividade. Use entre sessões para decidir onde atuar.
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
- - `morph-spec claim [feature] [--release] [--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. **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`.
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
66
 
67
67
  ## Skills core (em `.claude/skills/`)
@@ -251,7 +251,7 @@ Para alterar o modo: editar `.morph/config/config.json → trust` (`"auto"` ou `
251
251
 
252
252
  **Comportamento padrão fora de gate:** trabalhe com autonomia total. Gates só existem nestes três pontos. Não pause para confirmação no meio de implementação a menos que encontre uma ambiguidade de escopo que o mandate não cobre — nesse caso, registre a ambiguidade no decisions.md, resolva da forma mais conservadora possível, e sinalize na entrega da task.
253
253
 
254
- **A aprovação de gate é unificada nos comandos da CLI — a LLM nunca edita o estado à mão para aprovar um gate.** Em `trust: "auto"` (sem `AskUserQuestion`), a skill aprova o gate via `morph-spec approve <feature> <gate> --mode auto`; para aprovar o gate da fase corrente **e** já criar a pasta da próxima fase num passo só, use `morph-spec advance <feature>` (adicione `--design` para tomar o ramo opcional de uiux a partir da proposta) — isso substitui o Edit manual de state.json + `mkdir` da pasta de fase. Os scores por task também têm comando dedicado: `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. **O hook state-sync.js** cobre o caminho manual: quando uma `AskUserQuestion` é respondida com label começando em "aprovado", ele registra o gate correspondente no `feature.json` autoritativo da feature com `approvedBy: "manual"` (um humano aprovou no loop), na forma canônica. Nos dois caminhos o gate acaba gravado em `.morph/features/{feature}/feature.json` — nunca via Edit à mão de `state.json`.
254
+ **A aprovação de gate é unificada nos comandos da CLI — a LLM nunca edita o estado à mão para aprovar um gate.** Em `trust: "auto"` (sem `AskUserQuestion`), a skill aprova o gate via `morph-spec approve <feature> <gate> --mode auto`; para aprovar o gate da fase corrente **e** já criar a pasta da próxima fase num passo só, use `morph-spec advance <feature>` (adicione `--design` para tomar o ramo opcional de uiux a partir da proposta) — isso substitui o Edit manual de state.json + `mkdir` da pasta de fase. Os scores por task também têm comando dedicado: `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. **O hook state-sync.js** cobre o caminho manual: quando uma `AskUserQuestion` é respondida com label começando em "aprovado", ele registra o gate correspondente no `feature.json` autoritativo da feature com `approvedBy: "manual"` (um humano aprovou no loop), na forma canônica. **A feature é identificada pelo nome que a pergunta cita** — e o `feature.json` gravado é o da árvore que possui essa feature (raiz ou worktree), nunca o da "feature ativa" de quem por acaso rodou a sessão. Por isso os templates de gate nomeiam a feature: uma pergunta sem nome, numa árvore com mais de uma feature, faz o hook **recusar a gravar** e avisar — aprove então por `morph-spec approve <feature> <gate>`. Nos dois caminhos o gate acaba gravado em `.morph/features/{feature}/feature.json` — nunca via Edit à mão de `state.json`.
255
255
 
256
256
  **Registre quem aprovou cada gate (`approvedBy`):** o gate é um objeto `{ approved, timestamp, approvedBy }`.
257
257
  - `approvedBy: "auto"` — gate avançado por política `trust: auto` sem decisão humana.
@@ -143,9 +143,9 @@
143
143
  ],
144
144
  "stack": ["*"],
145
145
  "standards": [],
146
- "tools": "Read, Glob, Grep",
146
+ "tools": "Read, Glob, Grep, Bash",
147
147
  "model": "opus",
148
- "persona": "Você aplica as rubricas em .morph/framework/evals/rubrics/. Você não participou da implementação — avalia apenas o mandate, o diff e a saída real dos testes que o orquestrador fornece, com contexto fresco. 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."
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
149
  },
150
150
  "scout": {
151
151
  "title": "Codebase Scout",
@@ -18,7 +18,6 @@
18
18
  */
19
19
 
20
20
  import { existsSync } from 'fs';
21
- import { resolve } from 'path';
22
21
  import { readStdin } from '../../shared/stdin-reader.js';
23
22
  import { stateExists, getFeaturePhase, isGateApproved } from '../../shared/state-reader.js';
24
23
  import {
@@ -43,7 +42,7 @@ const PHASE_DIR_ENTRY_GATE = {
43
42
  };
44
43
  import { block, pass } from '../../shared/hook-response.js';
45
44
  import { logHookActivity } from '../../shared/activity-logger.js';
46
- import { getFilePath } from '../../shared/payload-utils.js';
45
+ import { getFilePath, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
47
46
 
48
47
  try {
49
48
  // Amend mode: bypass phase write enforcement for legitimate corrections
@@ -52,8 +51,6 @@ try {
52
51
  pass();
53
52
  }
54
53
 
55
- if (!stateExists()) pass();
56
-
57
54
  const payload = await readStdin();
58
55
  if (!payload) pass();
59
56
 
@@ -63,10 +60,19 @@ try {
63
60
  // Only check files inside .morph/features/
64
61
  if (!isFeaturePath(filePath)) pass();
65
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
+
66
72
  const featureName = extractFeatureName(filePath);
67
73
  if (!featureName) pass();
68
74
 
69
- const phase = getFeaturePhase(featureName);
75
+ const phase = getFeaturePhase(featureName, root);
70
76
  if (!phase) pass();
71
77
 
72
78
  // During implement phase, all writes are allowed (source code is unrestricted)
@@ -80,7 +86,7 @@ try {
80
86
 
81
87
  if (targetDir !== allowedDir) {
82
88
  // Allow editing already-existing files from previous phases
83
- const absolutePath = resolve(process.cwd(), filePath);
89
+ const absolutePath = resolveTargetPath(filePath, root);
84
90
  if (existsSync(absolutePath)) {
85
91
  logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
86
92
  pass(); // editing existing file — allow
@@ -91,19 +97,25 @@ try {
91
97
  // this, the first file of a new phase can never be written (the phase only
92
98
  // advances once that file exists) — the deadlock this hook used to create.
93
99
  const entryGate = PHASE_DIR_ENTRY_GATE[targetDir];
94
- if (entryGate && isGateApproved(featureName, entryGate)) {
100
+ if (entryGate && isGateApproved(featureName, entryGate, root)) {
95
101
  logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
96
102
  pass();
97
103
  }
98
104
 
99
105
  const phaseLabel = phase.toUpperCase();
106
+ // Name the tree the verdict came from whenever it is NOT the session's own.
107
+ // The phase read is per-tree, so "which .morph/ said proposal?" is the first
108
+ // question anyone debugging this asks.
109
+ const treeNote = root !== process.cwd().replace(/\\/g, '/')
110
+ ? `\n(phase read from the tree that owns the file: ${root})`
111
+ : '';
100
112
  logHookActivity('enforce-phase-writes', 'PreToolUse', 'blocked');
101
113
  block(
102
114
  `MORPH-SPEC: Writing to '${targetDir}/' is not allowed during ${phaseLabel} phase.\n` +
103
- `Current phase '${phase}' only permits writes to '${allowedDir}/'.\n\n` +
115
+ `Current phase '${phase}' only permits writes to '${allowedDir}/'.${treeNote}\n\n` +
104
116
  `To move forward, approve the current gate and scaffold the next phase in\n` +
105
117
  `one step: morph-spec advance ${featureName}\n` +
106
- `(this approves the gate that unlocks the next phase folder).`
118
+ `(run it in that tree — the gate is recorded in its own feature.json).`
107
119
  );
108
120
  }
109
121
 
@@ -25,9 +25,11 @@
25
25
  */
26
26
 
27
27
  import { readFileSync } from 'fs';
28
- import { loadState, stateExists } from '../../shared/state-reader.js';
28
+ import { loadState, stateExists, getActiveFeature } from '../../shared/state-reader.js';
29
29
  import { injectContext, pass } from '../../shared/hook-response.js';
30
30
  import { logHookActivity } from '../../shared/activity-logger.js';
31
+ import { resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
32
+ import { locateFeatureForQuestion } from '../../shared/feature-locator.js';
31
33
 
32
34
  function readStdin() {
33
35
  try {
@@ -41,12 +43,38 @@ function projectDir() {
41
43
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
42
44
  }
43
45
 
44
- function findActiveFeature(state) {
45
- if (!state.features) return null;
46
- const entries = Object.entries(state.features);
47
- const active = entries.find(([, f]) => f.status === 'in_progress');
48
- if (active) return active[0];
49
- return entries[0]?.[0] ?? null;
46
+ /** Feature slug from a `.morph/features/{name}/feature.json` path. */
47
+ function featureFromStatePath(filePath) {
48
+ const fp = String(filePath ?? '').replace(/\\/g, '/');
49
+ return (fp.match(/\.morph\/features\/([^/]+)\/feature\.json$/) ?? [])[1] ?? null;
50
+ }
51
+
52
+ /**
53
+ * The tree owning an edited gate-state file. `resolveMorphRoot` covers the v5
54
+ * per-feature `feature.json`; the legacy `.morph/state.json` has no feature
55
+ * segment to anchor on, so strip its own suffix instead.
56
+ */
57
+ function rootOfGateStateFile(filePath, fallback) {
58
+ const abs = resolveTargetPath(filePath, fallback);
59
+ const suffix = '/.morph/state.json';
60
+ if (abs.endsWith(suffix)) return abs.slice(0, -suffix.length) || fallback;
61
+ return resolveMorphRoot(abs, fallback);
62
+ }
63
+
64
+ /** Every question/header text this AskUserQuestion call surfaced. */
65
+ function askedTexts(input) {
66
+ const texts = [];
67
+ const questions = input.tool_input?.questions;
68
+ if (Array.isArray(questions)) {
69
+ for (const q of questions) {
70
+ if (typeof q?.question === 'string') texts.push(q.question);
71
+ if (typeof q?.header === 'string') texts.push(q.header);
72
+ }
73
+ }
74
+ // Fallback: some payloads only surface answered question keys.
75
+ const answers = input.tool_response?.answers ?? input.tool_response?.responses;
76
+ if (answers && typeof answers === 'object') texts.push(...Object.keys(answers));
77
+ return texts;
50
78
  }
51
79
 
52
80
  /** Map a gate question's text to a gate id. Mirrors state-sync.js. */
@@ -69,25 +97,23 @@ function getDecision(state, feature, gate) {
69
97
  * The LLM just asked a gate question. If the persisted decision said this
70
98
  * gate should auto-pass (pause:false), the human was interrupted needlessly.
71
99
  */
72
- function checkAskUserQuestion(input, state, feature, cwd) {
73
- // Questions live in tool_input.questions[]; answers in tool_response.
74
- const questions = input.tool_input?.questions;
75
- const askedTexts = [];
76
- if (Array.isArray(questions)) {
77
- for (const q of questions) {
78
- if (typeof q?.question === 'string') askedTexts.push(q.question);
79
- if (typeof q?.header === 'string') askedTexts.push(q.header);
80
- }
81
- }
82
- // Fallback: some payloads only surface answered question keys.
83
- const answers = input.tool_response?.answers ?? input.tool_response?.responses;
84
- if (answers && typeof answers === 'object') {
85
- askedTexts.push(...Object.keys(answers));
86
- }
87
-
88
- for (const text of askedTexts) {
100
+ function checkAskUserQuestion(input, sessionRoot) {
101
+ for (const text of askedTexts(input)) {
89
102
  const gate = gateFromQuestion(text);
90
103
  if (!gate) continue;
104
+
105
+ // Check the decision of the feature the question NAMES, in its own tree.
106
+ // Reading the session tree's first in_progress feature made this guard
107
+ // compare a gate against an unrelated feature's gateDecisions — a warning
108
+ // about the wrong thing is worse than silence, so an unattributable
109
+ // question is skipped rather than guessed at (state-sync already tells the
110
+ // operator when a gate answer could not be attributed).
111
+ const located = locateFeatureForQuestion(text, sessionRoot);
112
+ if (!located) continue;
113
+ const { name: feature, root: cwd } = located;
114
+ if (!stateExists(cwd)) continue; // fail-open
115
+
116
+ const state = loadState(cwd);
91
117
  const decision = getDecision(state, feature, gate);
92
118
  if (!decision) continue; // no persisted decision → nothing to verify
93
119
  if (decision.pause === false) {
@@ -121,8 +147,19 @@ function isGateStateFile(filePath) {
121
147
  * feature.json in v5). If a gate flipped to approvedBy:"auto" but the
122
148
  * persisted decision said pause:true, a required human pause was skipped.
123
149
  */
124
- function checkStateEdit(input, state, feature, cwd) {
125
- if (!isGateStateFile(input.tool_input?.file_path)) return pass();
150
+ function checkStateEdit(input, sessionRoot) {
151
+ const filePath = input.tool_input?.file_path;
152
+ if (!isGateStateFile(filePath)) return pass();
153
+
154
+ // The edited file names its own tree — and, for the v5 per-feature
155
+ // feature.json, its own feature. Only the legacy `.morph/state.json` shape
156
+ // still needs a most-recently-updated fallback within that tree.
157
+ const cwd = rootOfGateStateFile(filePath, sessionRoot);
158
+ if (!stateExists(cwd)) return pass(); // fail-open
159
+ const state = loadState(cwd);
160
+ if (!state) return pass();
161
+ const feature = featureFromStatePath(filePath) || getActiveFeature(cwd)?.name;
162
+ if (!feature) return pass();
126
163
 
127
164
  let checked = false;
128
165
  const gates = state.features?.[feature]?.approvalGates ?? {};
@@ -150,8 +187,9 @@ function checkStateEdit(input, state, feature, cwd) {
150
187
  }
151
188
 
152
189
  function main() {
153
- const cwd = projectDir();
154
- if (!stateExists(cwd)) return pass();
190
+ // Starting point only — each branch resolves the tree it actually inspects
191
+ // (from the edited path, or from the feature the question names).
192
+ const sessionRoot = projectDir();
155
193
 
156
194
  const input = readStdin();
157
195
  const toolName = input.tool_name;
@@ -166,13 +204,8 @@ function main() {
166
204
  }
167
205
 
168
206
  try {
169
- const state = loadState(cwd);
170
- if (!state) return pass();
171
- const feature = findActiveFeature(state);
172
- if (!feature) return pass();
173
-
174
- if (toolName === 'AskUserQuestion') return checkAskUserQuestion(input, state, feature, cwd);
175
- if (toolName === 'Edit' || toolName === 'Write') return checkStateEdit(input, state, feature, cwd);
207
+ if (toolName === 'AskUserQuestion') return checkAskUserQuestion(input, sessionRoot);
208
+ return checkStateEdit(input, sessionRoot);
176
209
  } catch {
177
210
  // fail-open
178
211
  }
@@ -24,7 +24,6 @@
24
24
  */
25
25
 
26
26
  import { readFileSync } from 'fs';
27
- import { isAbsolute, join } from 'path';
28
27
  import { readStdin } from '../../shared/stdin-reader.js';
29
28
  import { stateExists, isGateApproved } from '../../shared/state-reader.js';
30
29
  import {
@@ -36,7 +35,7 @@ import {
36
35
  } from '../../shared/phase-utils.js';
37
36
  import { block, pass } from '../../shared/hook-response.js';
38
37
  import { logHookActivity } from '../../shared/activity-logger.js';
39
- import { getFilePath, getToolName } from '../../shared/payload-utils.js';
38
+ import { getFilePath, getToolName, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
40
39
  import { validateTasksDoc, outputsDivergenceViolations } from '../../shared/tasks-validator.js';
41
40
 
42
41
  /**
@@ -106,8 +105,6 @@ try {
106
105
  pass();
107
106
  }
108
107
 
109
- if (!stateExists()) pass();
110
-
111
108
  const payload = await readStdin();
112
109
  if (!payload) pass();
113
110
 
@@ -117,6 +114,14 @@ try {
117
114
  // Only check files inside .morph/features/
118
115
  if (!isFeaturePath(filePath)) pass();
119
116
 
117
+ // The gate that locks this file is recorded in the feature.json of the tree
118
+ // that OWNS it. Asking the session's tree instead made this guard go DARK in
119
+ // a secondary worktree: the root had no approved gate, so a post-Gate-2
120
+ // spec.md rewrite sailed through. Same defect as enforce-phase-writes,
121
+ // opposite direction — there it over-blocked, here it under-protected.
122
+ const root = resolveMorphRoot(filePath, process.cwd());
123
+ if (!stateExists(root)) pass();
124
+
120
125
  const featureName = extractFeatureName(filePath);
121
126
  if (!featureName) pass();
122
127
 
@@ -127,7 +132,7 @@ try {
127
132
  if (!requiredGate) pass();
128
133
 
129
134
  // Gate not yet approved — allow editing
130
- if (!isGateApproved(featureName, requiredGate)) {
135
+ if (!isGateApproved(featureName, requiredGate, root)) {
131
136
  logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
132
137
  pass();
133
138
  }
@@ -143,7 +148,7 @@ try {
143
148
  // Build the proposed document from either a full Write or a targeted Edit,
144
149
  // then semantic-diff it against the current file on disk. Any failure to
145
150
  // read / parse / apply → fail-open (pass), never a false block.
146
- const absPath = isAbsolute(filePath) ? filePath : join(process.cwd(), filePath);
151
+ const absPath = resolveTargetPath(filePath, root);
147
152
  let oldDoc, newDoc;
148
153
  try {
149
154
  const original = readFileSync(absPath, 'utf-8');
@@ -13,7 +13,14 @@
13
13
 
14
14
  import { readFileSync } from 'fs';
15
15
  import { loadState, stateExists } from '../../shared/state-reader.js';
16
- import { persistFeature, setGateApproved, createDefaultFeature } from '../../shared/state-writer.js';
16
+ import { persistFeature, setGateApproved, createDefaultFeature, readFeatureFile } from '../../shared/state-writer.js';
17
+ import { resolveMorphRoot } from '../../shared/payload-utils.js';
18
+ import {
19
+ locateFeatureForQuestion,
20
+ allWorkTrees,
21
+ featureNamesIn,
22
+ unrecognizedSlugLike,
23
+ } from '../../shared/feature-locator.js';
17
24
 
18
25
  const PHASE_FOLDERS = {
19
26
  '0-proposal': 'proposal',
@@ -87,14 +94,6 @@ function trackOutput(state, featureName, filePath) {
87
94
  return wasMissing;
88
95
  }
89
96
 
90
- function findActiveFeature(state) {
91
- if (!state.features) return null;
92
- const entries = Object.entries(state.features);
93
- const active = entries.find(([, f]) => f.status === 'in_progress');
94
- if (active) return active[0];
95
- return entries[0]?.[0] ?? null;
96
- }
97
-
98
97
  function isApprovalAnswer(answer) {
99
98
  if (typeof answer !== 'string') return false;
100
99
  const lower = answer.toLowerCase();
@@ -124,11 +123,17 @@ function unmetGatePrereqs(gate, gates) {
124
123
  return (GATE_PREREQ[gate] || []).filter((p) => !isApproved(p));
125
124
  }
126
125
 
127
- function handleWriteEdit(input, cwd) {
126
+ function handleWriteEdit(input, sessionRoot) {
128
127
  const filePath = input.tool_input?.file_path;
129
128
  const featureName = extractFeatureName(filePath);
130
129
  if (!featureName) return false;
131
130
 
131
+ // Sync the tree that OWNS the written file. Syncing the session's tree
132
+ // instead registered the feature (and its outputs) in the wrong `.morph/`
133
+ // whenever a root-rooted session wrote into a secondary worktree.
134
+ const cwd = resolveMorphRoot(filePath, sessionRoot);
135
+ if (!stateExists(cwd)) return false;
136
+
132
137
  const state = loadState(cwd);
133
138
  if (!state) return false;
134
139
 
@@ -147,23 +152,56 @@ function handleWriteEdit(input, cwd) {
147
152
  return true;
148
153
  }
149
154
 
150
- function handleAskUserQuestion(input, cwd) {
155
+ function handleAskUserQuestion(input, sessionRoot) {
151
156
  const answers = input.tool_response?.answers ?? input.tool_response?.responses ?? {};
152
157
  if (typeof answers !== 'object' || answers === null) return null;
153
158
 
154
- const state = loadState(cwd);
155
- if (!state) return null;
156
-
157
- const featureName = findActiveFeature(state);
158
- if (!featureName) return null;
159
-
160
159
  const warnings = [];
161
160
  for (const [question, answer] of Object.entries(answers)) {
162
161
  if (!isApprovalAnswer(answer)) continue;
163
162
  const gate = gateFromQuestion(question);
164
163
  if (!gate) continue;
165
164
 
166
- const gates = state.features[featureName].approvalGates;
165
+ // Attribute the approval to the feature the QUESTION names, in the tree
166
+ // that owns it — never to "the first in_progress feature of whatever tree
167
+ // the session sits in", which is how a Gate 2 for one feature was stamped
168
+ // onto `export-hub`, leaving it with G2 approved and G1 never asked.
169
+ const located = locateFeatureForQuestion(question, sessionRoot);
170
+ if (!located) {
171
+ // Refuse to guess. A gate on the wrong feature inverts gate order and
172
+ // invites a second stamp on the real owner; writing nothing costs one
173
+ // explicit remediation step.
174
+ //
175
+ // The remediation differs by WHY it refused. When the question names a
176
+ // specific slug that no known tree has registered (a hotfix gate asked
177
+ // before `create` ran — the case that used to fall through to "the
178
+ // tree's one feature"), `morph-spec approve <that-name> <gate>` is a
179
+ // dead end: approve.js does `getFeature()` and hard-refuses "Feature
180
+ // not found". Name the real fix instead of the wrong command.
181
+ const allNames = allWorkTrees(sessionRoot).flatMap((r) => featureNamesIn(r));
182
+ const candidate = unrecognizedSlugLike(question, allNames);
183
+ const remediation = candidate
184
+ ? `"${candidate}" não está registrada em nenhuma árvore conhecida. Se é uma feature nova, crie-a primeiro: ` +
185
+ `morph-spec create ${candidate} --description "<o quê/porquê>" --type <feature|bug|chore|hotfix> — ` +
186
+ `depois aprove com: morph-spec approve ${candidate} ${gate}`
187
+ : `Aprove explicitamente: morph-spec approve <feature> ${gate}`;
188
+ warnings.push(
189
+ `⚠️ [morph-spec] APROVAÇÃO NÃO ATRIBUÍDA — a resposta "aprovado" ao Gate "${gate}" NÃO foi gravada: ` +
190
+ `a pergunta não nomeia nenhuma feature conhecida ("${question}"). ` +
191
+ `Gravar por palpite carimbaria o gate na feature errada. ${remediation}`
192
+ );
193
+ continue;
194
+ }
195
+
196
+ const { name: featureName, root } = located;
197
+ if (!stateExists(root)) continue;
198
+
199
+ // Prefer the authoritative feature.json; fall back to loadState's merged
200
+ // view so a feature carried only by the thin index still reports its real
201
+ // gates (otherwise every predecessor reads as unapproved and the
202
+ // out-of-order warning below fires on a perfectly ordered approval).
203
+ const feature = readFeatureFile(featureName, root) || loadState(root)?.features?.[featureName];
204
+ const gates = feature?.approvalGates;
167
205
  const existing = gates?.[gate];
168
206
  const alreadyApproved = existing === true || existing?.approved === true;
169
207
  if (alreadyApproved) continue;
@@ -186,17 +224,19 @@ function handleAskUserQuestion(input, cwd) {
186
224
  // (`morph-spec approve`) produce one consistent shape
187
225
  // ({ approved, timestamp, approvedBy }) in the authoritative feature.json —
188
226
  // and a manual approval stays distinguishable from a policy auto-approval.
189
- setGateApproved(featureName, gate, { approvedBy: 'manual' }, cwd);
227
+ setGateApproved(featureName, gate, { approvedBy: 'manual' }, root);
190
228
  }
191
229
  return warnings.length ? warnings.join('\n') : null;
192
230
  }
193
231
 
194
232
  async function main() {
195
- const cwd = projectDir();
196
- if (!stateExists(cwd)) {
197
- exit();
198
- return;
199
- }
233
+ // The SESSION's root. It is only a starting point: which `.morph/` actually
234
+ // gets read and written is decided per event — by the target path for
235
+ // Write/Edit, by the feature the question names for AskUserQuestion. The
236
+ // `stateExists` gate therefore moved into each handler, against the tree that
237
+ // handler resolved; gating on the session's tree up here would skip work that
238
+ // belongs to a worktree (and, worse, silently pass it to the wrong one).
239
+ const sessionRoot = projectDir();
200
240
 
201
241
  const input = readStdin();
202
242
  const toolName = input.tool_name;
@@ -204,9 +244,9 @@ async function main() {
204
244
  let warning = null;
205
245
  try {
206
246
  if (toolName === 'Write' || toolName === 'Edit') {
207
- handleWriteEdit(input, cwd);
247
+ handleWriteEdit(input, sessionRoot);
208
248
  } else if (toolName === 'AskUserQuestion') {
209
- warning = handleAskUserQuestion(input, cwd);
249
+ warning = handleAskUserQuestion(input, sessionRoot);
210
250
  }
211
251
  } catch {
212
252
  // fail-open
@@ -30,7 +30,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from
30
30
  import { join } from 'path';
31
31
  import { readStdin } from '../../shared/stdin-reader.js';
32
32
  import { stateExists } from '../../shared/state-reader.js';
33
- import { getFilePath } from '../../shared/payload-utils.js';
33
+ import { getFilePath, resolveMorphRoot } from '../../shared/payload-utils.js';
34
34
 
35
35
  function projectDir() {
36
36
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -69,9 +69,6 @@ function sessionId(d) {
69
69
  const STATUS_MAP = { done: 'completed', skipped: 'skipped', blocked: 'failed' };
70
70
 
71
71
  async function main() {
72
- const cwd = projectDir();
73
- if (!stateExists(cwd)) return;
74
-
75
72
  const payload = await readStdin();
76
73
  if (!payload) return;
77
74
 
@@ -79,6 +76,14 @@ async function main() {
79
76
  const feature = filePath ? extractFeature(filePath) : null;
80
77
  if (!feature) return;
81
78
 
79
+ // Read the sources — and write the trace — in the tree that owns the written
80
+ // tasks.json. Resolving from the session instead made this hook INERT for
81
+ // every worktree feature: the root's copy of the feature has no
82
+ // `2-plan/tasks.json`, so the `existsSync` guard below returned silently and
83
+ // no trace was ever produced for work done in a worktree.
84
+ const cwd = resolveMorphRoot(filePath, projectDir());
85
+ if (!stateExists(cwd)) return;
86
+
82
87
  const featureDir = join(cwd, '.morph', 'features', feature);
83
88
  const tasksPath = join(featureDir, '2-plan', 'tasks.json');
84
89
  if (!existsSync(tasksPath)) return;
@@ -146,9 +146,18 @@ function buildWorktreeContext(dir, feature) {
146
146
  const handoffLine = buildHandoffLine(dir, feature);
147
147
  if (handoffLine) text += `\n${handoffLine}`;
148
148
 
149
- const missingJunctions = !existsSync(join(dir, 'node_modules')) || !existsSync(join(dir, '.claude'));
150
- if (missingJunctions) {
151
- text += `\n⚠ Infra compartilhada ausente (node_modules/.claude). Rode: morph-spec worktree link --all`;
149
+ // `.morph/framework` is checked alongside the other two because the hook
150
+ // commands resolve to `$CLAUDE_PROJECT_DIR/.morph/framework/hooks/...`: if
151
+ // that junction is missing, `node` finds no file and EVERY guardrail in this
152
+ // worktree is silently dark — the worst failure mode of the lot, and the one
153
+ // the old two-item check could not see.
154
+ const missing = ['node_modules', '.claude', join('.morph', 'framework')]
155
+ .filter((rel) => !existsSync(join(dir, rel)));
156
+ if (missing.length) {
157
+ text += `\n⚠ Infra compartilhada ausente (${missing.join(', ').replace(/\\/g, '/')}). Rode: morph-spec worktree link --all`;
158
+ if (missing.includes(join('.morph', 'framework'))) {
159
+ text += `\n Sem .morph/framework os hooks do morph-spec NÃO executam neste worktree (nenhum guardrail ativo).`;
160
+ }
152
161
  }
153
162
 
154
163
  return text;