@polymorphism-tech/morph-spec 8.12.0 → 8.14.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 +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +1 -1
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +21 -9
- package/framework/hooks/claude-code/core/gate-guard.js +68 -35
- package/framework/hooks/claude-code/core/protect-spec-files.js +11 -6
- package/framework/hooks/claude-code/core/state-sync.js +66 -26
- package/framework/hooks/claude-code/core/trace-autogen.js +9 -4
- package/framework/hooks/claude-code/core/worktree-context.js +12 -3
- package/framework/hooks/shared/feature-locator.js +164 -0
- package/framework/hooks/shared/payload-utils.js +71 -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 +2 -2
- 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 +9 -3
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +2 -2
- package/framework/skills/core/morph-proposal/SKILL.md +2 -2
- package/framework/skills/core/morph-review/SKILL.md +2 -2
- 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/project/claim.js +76 -7
- package/src/commands/state/advance.js +43 -0
- 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.
|
|
6
|
+
**Version:** 8.14.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.14.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('
|
|
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', '
|
|
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
|
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
package/framework/CLAUDE.md
CHANGED
|
@@ -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/`)
|
package/framework/MORPH.md
CHANGED
|
@@ -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.
|
|
@@ -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 =
|
|
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}/'
|
|
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
|
-
`(
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
73
|
-
|
|
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,
|
|
125
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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' },
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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,
|
|
247
|
+
handleWriteEdit(input, sessionRoot);
|
|
208
248
|
} else if (toolName === 'AskUserQuestion') {
|
|
209
|
-
warning = handleAskUserQuestion(input,
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate which tree owns a feature, and which feature a gate question is about.
|
|
3
|
+
*
|
|
4
|
+
* `resolveMorphRoot` (payload-utils) answers this from a file path. Some hook
|
|
5
|
+
* events carry no path at all — an `AskUserQuestion` gate approval is the one
|
|
6
|
+
* that matters — and those were resolved by "the first in_progress feature of
|
|
7
|
+
* whatever tree the session happens to sit in". That is how a Gate 2 approval
|
|
8
|
+
* for one feature landed on `export-hub`, an unrelated feature that merely
|
|
9
|
+
* happened to be active in the primary root.
|
|
10
|
+
*
|
|
11
|
+
* The honest signal for a pathless event is the question text itself: every
|
|
12
|
+
* gate template in the core skills names its feature — "Gate 1 — Proposta de
|
|
13
|
+
* {feature} aprovada?", "Gate 2 — Plano de {feature} aprovado?", "Gate 3 —
|
|
14
|
+
* Review de {feature} aprovado?", "Gate 3 — Hotfix de {feature} pronto para
|
|
15
|
+
* ship?". Rather than parse those sentences (which would break the moment a
|
|
16
|
+
* template is reworded), match the question against the set of feature slugs
|
|
17
|
+
* that actually exist on disk.
|
|
18
|
+
*
|
|
19
|
+
* When the text names no known feature, the caller must REFUSE TO GUESS.
|
|
20
|
+
* Stamping a gate onto the wrong feature is close to unrecoverable — it inverts
|
|
21
|
+
* gate order and invites a second stamp on the real owner; writing nothing
|
|
22
|
+
* costs one `morph-spec approve`.
|
|
23
|
+
*
|
|
24
|
+
* Fail-open by contract: every function swallows errors and returns an empty
|
|
25
|
+
* result rather than throwing.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execSync } from 'child_process';
|
|
29
|
+
import { loadState } from './state-reader.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Feature slugs known to `root`.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately `loadState` and not a `readdirSync` of `.morph/features/`: state
|
|
35
|
+
* lives in BOTH shapes — the thin `.morph/state.json` index and the per-feature
|
|
36
|
+
* `feature.json` — and `loadState` is the one place that already merges them.
|
|
37
|
+
* Listing directories alone misses every feature carried only by the index.
|
|
38
|
+
*/
|
|
39
|
+
export function featureNamesIn(root) {
|
|
40
|
+
try {
|
|
41
|
+
const state = loadState(root);
|
|
42
|
+
return state?.features ? Object.keys(state.features) : [];
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every working tree of this repo — the primary root plus every registered
|
|
50
|
+
* secondary worktree. Fail-open to `[sessionRoot]` when git is unavailable or
|
|
51
|
+
* the directory is not a repo.
|
|
52
|
+
*/
|
|
53
|
+
export function allWorkTrees(sessionRoot) {
|
|
54
|
+
const roots = [sessionRoot];
|
|
55
|
+
try {
|
|
56
|
+
const out = execSync('git worktree list --porcelain', {
|
|
57
|
+
cwd: sessionRoot,
|
|
58
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
59
|
+
timeout: 2000,
|
|
60
|
+
}).toString();
|
|
61
|
+
for (const line of out.split('\n')) {
|
|
62
|
+
if (!line.startsWith('worktree ')) continue;
|
|
63
|
+
const path = line.slice('worktree '.length).trim();
|
|
64
|
+
if (path && !roots.includes(path)) roots.push(path);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
/* fail-open — the session root alone */
|
|
68
|
+
}
|
|
69
|
+
return roots;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The single feature slug named in `text`, or null.
|
|
76
|
+
*
|
|
77
|
+
* Slug boundaries are `[^a-z0-9-]` so a longer name never matches inside a
|
|
78
|
+
* shorter one (`export` must not match the text "export-hub"). Zero matches
|
|
79
|
+
* means the text named nothing; two or more means it named two features and is
|
|
80
|
+
* ambiguous. Both are null — "I don't know" and "it's ambiguous" must lead to
|
|
81
|
+
* the same refusal, not to a coin flip.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} text
|
|
84
|
+
* @param {string[]} names - candidate slugs
|
|
85
|
+
* @returns {string|null}
|
|
86
|
+
*/
|
|
87
|
+
export function featureNamedIn(text, names) {
|
|
88
|
+
if (typeof text !== 'string' || !Array.isArray(names) || names.length === 0) return null;
|
|
89
|
+
const haystack = text.toLowerCase();
|
|
90
|
+
const hits = names.filter((name) =>
|
|
91
|
+
new RegExp(`(^|[^a-z0-9-])${escapeRe(String(name).toLowerCase())}([^a-z0-9-]|$)`).test(haystack)
|
|
92
|
+
);
|
|
93
|
+
return hits.length === 1 ? hits[0] : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A token in `text` shaped like a feature slug (kebab-case, at least one
|
|
98
|
+
* hyphen — `morph-spec create`'s naming convention) that names none of
|
|
99
|
+
* `names`. Signals "the text is talking about a specific feature I don't
|
|
100
|
+
* know" as opposed to "the text names nothing in particular".
|
|
101
|
+
*
|
|
102
|
+
* This is what the single-feature fallback below was missing: a gate question
|
|
103
|
+
* for a feature not yet registered (no `create`, no `proposal.md` written
|
|
104
|
+
* yet — a fresh hotfix, say) names NO known feature either, and used to fall
|
|
105
|
+
* through to "the tree's one feature must be it". That is exactly how a Gate 3
|
|
106
|
+
* for `backfill-bool-filter` landed on `export-hub`, the tree's only
|
|
107
|
+
* registered feature at the time.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately narrow (requires a hyphen) to match real feature slugs
|
|
110
|
+
* (`export-hub`, `backfill-bool-filter`) without flagging ordinary prose —
|
|
111
|
+
* the fixed portions of every gate template ("Proposta", "aprovado", "Sinal",
|
|
112
|
+
* "pronto para ship"...) are single words with no internal hyphen.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} text
|
|
115
|
+
* @param {string[]} names - every feature slug known across all trees
|
|
116
|
+
* @returns {string|null}
|
|
117
|
+
*/
|
|
118
|
+
export function unrecognizedSlugLike(text, names) {
|
|
119
|
+
if (typeof text !== 'string') return null;
|
|
120
|
+
const known = new Set((names || []).map((n) => String(n).toLowerCase()));
|
|
121
|
+
const tokens = text.toLowerCase().match(/[a-z0-9]+(?:-[a-z0-9]+)+/g) || [];
|
|
122
|
+
return tokens.find((t) => !known.has(t)) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a gate question to `{ name, root }`.
|
|
127
|
+
*
|
|
128
|
+
* The session root is consulted first so the common case costs no git call, and
|
|
129
|
+
* so a feature present in BOTH trees resolves to the tree the session is
|
|
130
|
+
* actually in — the same tree `morph-spec approve` would write from that cwd.
|
|
131
|
+
* Only when the session root knows no such feature do we pay for enumerating
|
|
132
|
+
* the worktrees.
|
|
133
|
+
*
|
|
134
|
+
* @returns {{name: string, root: string}|null} null ⇒ caller must not guess
|
|
135
|
+
*/
|
|
136
|
+
export function locateFeatureForQuestion(text, sessionRoot) {
|
|
137
|
+
const trees = allWorkTrees(sessionRoot);
|
|
138
|
+
const localNames = featureNamesIn(sessionRoot);
|
|
139
|
+
|
|
140
|
+
const local = featureNamedIn(text, localNames);
|
|
141
|
+
if (local) return { name: local, root: sessionRoot };
|
|
142
|
+
|
|
143
|
+
for (const root of trees) {
|
|
144
|
+
if (root === sessionRoot) continue;
|
|
145
|
+
const found = featureNamedIn(text, featureNamesIn(root));
|
|
146
|
+
if (found) return { name: found, root };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The text names a specific feature none of the trees know — refuse rather
|
|
150
|
+
// than guess it meant the tree's one registered feature (see
|
|
151
|
+
// `unrecognizedSlugLike` above).
|
|
152
|
+
const allNames = trees.flatMap(featureNamesIn);
|
|
153
|
+
if (unrecognizedSlugLike(text, allNames)) return null;
|
|
154
|
+
|
|
155
|
+
// Unambiguous fallback: the session's tree holds exactly ONE feature, so
|
|
156
|
+
// there is no other feature the answer could have been about. This is the
|
|
157
|
+
// single-feature project — the common case — and refusing there would break
|
|
158
|
+
// gate questions phrased without a name ("Gate 1 — Proposta aprovada?").
|
|
159
|
+
// With two or more features the fallback is exactly the guess that put a
|
|
160
|
+
// Gate 2 on `export-hub`, so it stops here.
|
|
161
|
+
if (localNames.length === 1) return { name: localNames[0], root: sessionRoot };
|
|
162
|
+
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
@@ -47,6 +47,77 @@ export function resolveRootForFile(filePath, fallbackCwd, markers = ['package.js
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the project root that OWNS a `.morph/features/...` path — the tree
|
|
52
|
+
* whose `.morph/` the hook must read and write for that file.
|
|
53
|
+
*
|
|
54
|
+
* A hook process knows only where the SESSION lives (`process.cwd()` /
|
|
55
|
+
* `CLAUDE_PROJECT_DIR`). That is not the same thing as where the FILE lives: a
|
|
56
|
+
* session rooted in the primary repo can legitimately write to a feature that
|
|
57
|
+
* lives in a secondary git worktree. Resolving state from the process instead
|
|
58
|
+
* of the path is what made `enforce-phase-writes` block a legal `2-plan/` write
|
|
59
|
+
* (the root still had the feature at `proposal`) while `protect-spec-files`
|
|
60
|
+
* simultaneously went DARK there (the root had no approved gate to enforce) —
|
|
61
|
+
* one defect, opposite directions.
|
|
62
|
+
*
|
|
63
|
+
* The anchor is exact, so no marker walk is needed: everything before the
|
|
64
|
+
* file's own `.morph/features/` segment IS the owning root. `lastIndexOf` picks
|
|
65
|
+
* the CLOSEST enclosing `.morph/` — the worktree's, not the root it nests under.
|
|
66
|
+
*
|
|
67
|
+
* Note this cannot be solved by locating the hook file itself: `worktree setup`
|
|
68
|
+
* junctions `.morph/framework/` back to the primary root, so every worktree
|
|
69
|
+
* executes the very same hook file. Only the target path identifies the tree.
|
|
70
|
+
*
|
|
71
|
+
* Fail-open: never throws — returns `fallbackCwd` for any non-feature path.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} filePath - target file path (absolute or relative)
|
|
74
|
+
* @param {string} fallbackCwd - root to use when the path owns no `.morph/`
|
|
75
|
+
* @returns {string} the resolved project root
|
|
76
|
+
*/
|
|
77
|
+
export function resolveMorphRoot(filePath, fallbackCwd) {
|
|
78
|
+
try {
|
|
79
|
+
if (!filePath) return fallbackCwd;
|
|
80
|
+
const p = toAbsolutePosix(filePath, fallbackCwd);
|
|
81
|
+
|
|
82
|
+
const idx = p.lastIndexOf('/.morph/features/');
|
|
83
|
+
if (idx <= 0) return fallbackCwd;
|
|
84
|
+
return p.slice(0, idx);
|
|
85
|
+
} catch {
|
|
86
|
+
return fallbackCwd;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Is this an already-absolute path, on EITHER convention?
|
|
92
|
+
*
|
|
93
|
+
* `path.isAbsolute` is platform-bound: `C:/x` is absolute on win32 and relative
|
|
94
|
+
* on posix. That asymmetry is a real defect here, not a theoretical one — a
|
|
95
|
+
* Windows-shaped path reaching a posix `join()` produces `/cwd/C:/x`, silently
|
|
96
|
+
* resolving state against a directory that cannot exist. Recognizing the drive
|
|
97
|
+
* letter explicitly makes the answer the same everywhere (and is a no-op on
|
|
98
|
+
* win32, where `isAbsolute` already accepts it).
|
|
99
|
+
*/
|
|
100
|
+
const isAbsolutePath = (p) => isAbsolute(p) || /^[a-zA-Z]:\//.test(p);
|
|
101
|
+
|
|
102
|
+
/** Normalize to forward slashes and make absolute against `fallbackCwd`. */
|
|
103
|
+
function toAbsolutePosix(filePath, fallbackCwd) {
|
|
104
|
+
const p = String(filePath).replace(/\\/g, '/');
|
|
105
|
+
return isAbsolutePath(p) ? p : join(fallbackCwd, p).replace(/\\/g, '/');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Absolute path of a hook's target file, resolved against the root that owns
|
|
110
|
+
* it. Pairs with `resolveMorphRoot` so a hook never mixes "the tree I read
|
|
111
|
+
* state from" with "the file I read content from".
|
|
112
|
+
*
|
|
113
|
+
* @param {string} filePath
|
|
114
|
+
* @param {string} fallbackCwd
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function resolveTargetPath(filePath, fallbackCwd) {
|
|
118
|
+
return toAbsolutePosix(filePath || '', fallbackCwd);
|
|
119
|
+
}
|
|
120
|
+
|
|
50
121
|
/**
|
|
51
122
|
* Get the file path from a hook payload, normalizing backslashes.
|
|
52
123
|
* @param {Object} payload - Hook payload from stdin
|
|
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# create-umbraco-lp
|
|
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# migrate-lp-to-umbraco
|
|
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -220,7 +220,7 @@ O comando grava `gateDecisions.review = { pause, reason, signals, trust, decided
|
|
|
220
220
|
|
|
221
221
|
### 6. Pós Gate 3 — encerramento
|
|
222
222
|
|
|
223
|
-
- Aprovar o Gate 3: em auto, `morph-spec approve {feature} review --mode auto`; em manual, o hook state-sync registra a resposta "aprovado" em AskUserQuestion. O gate fica gravado no `feature.json` autoritativo da feature — nunca via Edit à mão de `state.json`.
|
|
223
|
+
- Aprovar o Gate 3: em auto, `morph-spec approve {feature} review --mode auto`; em manual, o hook state-sync registra a resposta "aprovado" em AskUserQuestion — **desde que a pergunta nomeie a feature** (o template acima nomeia; se você improvisar sem o nome e houver mais de uma feature na árvore, o hook recusa a gravar e avisa, e você aprova por `morph-spec approve`). O gate fica gravado no `feature.json` autoritativo da feature — nunca via Edit à mão de `state.json`.
|
|
224
224
|
- **Fechar a feature** com `morph-spec finish {feature} --pr|--merge` — um passo só: arquiva commitando `features/{f} → archive/{f}` dentro de `morph/{f}`, integra e desmonta o worktree (se houver). Escolha o modo com `AskUserQuestion` (header `"Integração"`):
|
|
225
225
|
- **Abrir PR** (`--pr`): faz `push` de `morph/{f}` + `gh pr create` com base na default — para review por outro humano/CI antes do merge. Exige `origin` + `gh` autenticado (checados **antes** de arquivar; se faltarem, `finish` recusa sem efeito colateral — caia para `--merge`). A branch é preservada e a sessão fica em `morph/{f}`.
|
|
226
226
|
- **Merge local** (`--merge`): merge `--no-ff` na default **localmente**, sem push — para o fluxo single-user direto. Remove a branch e o worktree.
|
|
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber um novo request de feature, antes de morph-plan
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-brainstorming
|
|
@@ -159,7 +159,7 @@ options:
|
|
|
159
159
|
**Riscos:** {principal} (sinais detectados: nenhum)
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
No caminho manual, o hook state-sync.js detecta a aprovação automaticamente (grava o gate no `feature.json` autoritativo, `approvedBy: "manual"`). Em modo auto sem AskUserQuestion, aprove via `morph-spec approve {feature} proposal --mode auto` (ou `morph-spec advance {feature}` para já criar a pasta 2-plan/) — nunca via Edit à mão de state.json.
|
|
162
|
+
No caminho manual, o hook state-sync.js detecta a aprovação automaticamente (grava o gate no `feature.json` autoritativo, `approvedBy: "manual"`) — **atribuindo pela feature nomeada na pergunta**; sem o nome, e com mais de uma feature na árvore, ele recusa a gravar e avisa (aprove então por `morph-spec approve {feature} proposal`). Em modo auto sem AskUserQuestion, aprove via `morph-spec approve {feature} proposal --mode auto` (ou `morph-spec advance {feature}` para já criar a pasta 2-plan/) — nunca via Edit à mão de state.json.
|
|
163
163
|
|
|
164
164
|
## Output esperado
|
|
165
165
|
- `.morph/features/{feature}/0-proposal/proposal.md` — proposta aprovada
|
|
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
|
|
6
6
|
user-invokable: false
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-eval
|
|
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-hotfix
|
|
@@ -98,7 +98,13 @@ options:
|
|
|
98
98
|
- { label: "rollback", description: "Desfazer o fix — voltar ao estado anterior" }
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
**NUNCA `morph-spec approve {feature} review --mode auto`** — o CLI recusa explicitamente essa combinação (`src/commands/state/approve.js`: `gateName === 'review' && feature.workType === 'hotfix' && approvedBy === 'auto'` é bloqueado) porque `forcedPauseGates` sobrepõe qualquer trust mode. Em contexto headless (sem `AskUserQuestion` disponível),
|
|
101
|
+
**NUNCA `morph-spec approve {feature} review --mode auto`** — o CLI recusa explicitamente essa combinação (`src/commands/state/approve.js`: `gateName === 'review' && feature.workType === 'hotfix' && approvedBy === 'auto'` é bloqueado) porque `forcedPauseGates` sobrepõe qualquer trust mode. Em contexto headless (sem `AskUserQuestion` disponível), **o `state-sync` NÃO cobre este caso**: ele é um hook `PostToolUse` com matcher `Write|Edit|AskUserQuestion`, e uma resposta em texto puro do usuário não é tool call — não gera evento e não grava gate nenhum. Esperar por ele deixa o Gate 3 por aprovar em silêncio. O caminho é registrar a aprovação humana explicitamente:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
morph-spec approve {feature} review --approver "<nome de quem aprovou>"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`--approver` é a única assinatura que passa pelo backstop em headless — as outras rotas de `resolveApprovedBy` (`--mode auto`, e "sem TTY") resolvem para `"auto"` e são recusadas. Ela é **auto-declarada**: o CLI não consegue verificar que houve um humano, só recusa as assinaturas definitivamente não-humanas. Por isso: **nunca invente o nome** — use `--approver` só quando o humano de fato aprovou, senão o backstop do hotfix vira teatro.
|
|
102
108
|
|
|
103
109
|
### 8. Pós-gate (o "test loop → ship" de `MORPH.md` §2.1)
|
|
104
110
|
|
|
@@ -119,7 +125,7 @@ options:
|
|
|
119
125
|
- `.morph/features/{feature}/2-plan/tasks.json` — T1 (fix) + T2 (verify/regressão)
|
|
120
126
|
- `.morph/features/{feature}/3-implement/recap.md` — evidência real de teste
|
|
121
127
|
- `.morph/features/{feature}/4-review/review-report.md` — diff + evidência, Gate 3 sempre humano
|
|
122
|
-
- Gates `proposal`/`plan` carimbados `approvedBy: "policy:hotfix"`; Gate 3 registrado `
|
|
128
|
+
- Gates `proposal`/`plan` carimbados `approvedBy: "policy:hotfix"`; Gate 3 registrado em `feature.json` (autoritativo) com uma **assinatura humana** — `"manual"` pelo `state-sync` (resposta ao `AskUserQuestion`), o nome passado em `--approver`, ou `$USER` num terminal interativo. **Nunca `"auto"`** — é o que o backstop recusa.
|
|
123
129
|
|
|
124
130
|
## Referências
|
|
125
131
|
|
|
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
|
|
6
6
|
user-invokable: false
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-implement
|
|
@@ -4,7 +4,7 @@ description: Guia a produção dos artefatos de planejamento de uma feature (use
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
@@ -186,7 +186,7 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
186
186
|
**Tasks:** {N} | Esforço total: {soma} | Caminho crítico: {IDs sem deps paralelas}
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
No caminho manual, o hook state-sync registra a aprovação automaticamente em answers de AskUserQuestion (grava o gate no `feature.json` autoritativo, `approvedBy: "manual"`). Em auto sem AskUserQuestion, aprove via `morph-spec approve {feature} plan --mode auto` (ou `morph-spec advance {feature}` para já criar a pasta da próxima fase) — nunca via Edit à mão de state.json.
|
|
189
|
+
No caminho manual, o hook state-sync registra a aprovação automaticamente em answers de AskUserQuestion (grava o gate no `feature.json` autoritativo, `approvedBy: "manual"`) — **atribuindo pela feature nomeada na pergunta**; sem o nome, e com mais de uma feature na árvore, ele recusa a gravar e avisa (aprove então por `morph-spec approve {feature} plan`). Em auto sem AskUserQuestion, aprove via `morph-spec approve {feature} plan --mode auto` (ou `morph-spec advance {feature}` para já criar a pasta da próxima fase) — nunca via Edit à mão de state.json.
|
|
190
190
|
|
|
191
191
|
## Output esperado
|
|
192
192
|
|
|
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-proposal
|
|
@@ -96,7 +96,7 @@ Escopo: {incluído} | Excluído: {exclusões}
|
|
|
96
96
|
Stack: {compliance}
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
No caminho manual, o hook `state-sync.js` registra a aprovação automaticamente via PostToolUse quando a `AskUserQuestion` é respondida com "aprovado" (grava o gate no `feature.json` autoritativo, `approvedBy: "manual"`). Em `trust: auto` (sem AskUserQuestion), **aprove via CLI — nunca via Edit à mão de state.json**:
|
|
99
|
+
No caminho manual, o hook `state-sync.js` registra a aprovação automaticamente via PostToolUse quando a `AskUserQuestion` é respondida com "aprovado" (grava o gate no `feature.json` autoritativo, `approvedBy: "manual"`) — **atribuindo pela feature nomeada na pergunta**; sem o nome, e com mais de uma feature na árvore, ele recusa a gravar e avisa (aprove então por `morph-spec approve {feature} <gate>`). Em `trust: auto` (sem AskUserQuestion), **aprove via CLI — nunca via Edit à mão de state.json**:
|
|
100
100
|
|
|
101
101
|
```
|
|
102
102
|
morph-spec approve {feature} proposal --mode auto
|
|
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-review
|
|
@@ -124,7 +124,7 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
|
|
|
124
124
|
**Recomendação:** Aprovado
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
-
No caminho manual, o hook state-sync detecta a aprovação em answers de AskUserQuestion e grava `approvalGates.review = { approved: true, timestamp, approvedBy: "manual" }` no `feature.json` autoritativo da feature. Em auto sem AskUserQuestion, aprove via `morph-spec approve {feature} review --mode auto` — nunca via Edit à mão de state.json.
|
|
127
|
+
No caminho manual, o hook state-sync detecta a aprovação em answers de AskUserQuestion e grava `approvalGates.review = { approved: true, timestamp, approvedBy: "manual" }` no `feature.json` autoritativo da feature — **a atribuição vem do nome da feature na pergunta** (o template acima o carrega). Pergunta sem nome, com mais de uma feature na árvore: o hook recusa a gravar e avisa; aprove então por `morph-spec approve {feature} review`. Em auto sem AskUserQuestion, aprove via `morph-spec approve {feature} review --mode auto` — nunca via Edit à mão de state.json.
|
|
128
128
|
|
|
129
129
|
**Próximo passo (não é desta skill):** com o Gate 3 aprovado, a feature fecha por `morph-spec finish {feature} --pr|--merge` — a `morph-apply` §6 conduz a escolha PR vs merge. Não arquive nem integre aqui.
|
|
130
130
|
|
|
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-stack-scan
|
|
@@ -4,7 +4,7 @@ description: Localiza e carrega um standard específico do STANDARDS.json sob de
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: quando precisar consultar um standard específico durante a implementação
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.14.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
package/package.json
CHANGED
|
@@ -4,19 +4,32 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Claims are acquired and heartbeaten AUTOMATICALLY (SessionStart hook +
|
|
6
6
|
* telemetry hook), so the operator never has to remember anything. This command
|
|
7
|
-
* exists for the
|
|
8
|
-
* releasing, leaving a claim that keeps
|
|
7
|
+
* exists for the two cases automation cannot cover:
|
|
8
|
+
* - a session that died without releasing, leaving a claim that keeps
|
|
9
|
+
* warning everyone until its TTL expires (`--release`);
|
|
10
|
+
* - an operator switching this SAME session/tree to a different feature
|
|
11
|
+
* mid-task. `morph-spec claim <feature>` (no flags) ACQUIRES it, moving
|
|
12
|
+
* the tree's previous claim in the same step — a manual release-then-
|
|
13
|
+
* reclaim was observed to just not happen mid-task, and the silent no-op
|
|
14
|
+
* this used to be (any unclaimed feature name printed "nothing here" and
|
|
15
|
+
* changed nothing) was worse: it looked like a query, not a skipped write.
|
|
9
16
|
*
|
|
10
|
-
*
|
|
11
|
-
* Always exits 0 except on a genuine misuse — the
|
|
12
|
-
* must never become a source of hard failures.
|
|
17
|
+
* `--list` forces the read-only view even when a feature is named. `--release`
|
|
18
|
+
* is the only other mutation. Always exits 0 except on a genuine misuse — the
|
|
19
|
+
* claim system is advisory and must never become a source of hard failures.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import chalk from 'chalk';
|
|
16
23
|
import { execSync } from 'child_process';
|
|
17
24
|
import { resolve } from 'path';
|
|
18
25
|
import { isGitRepo, resolvePrimaryRoot } from './worktree.js';
|
|
19
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
acquireClaim,
|
|
28
|
+
foreignClaim,
|
|
29
|
+
listClaims,
|
|
30
|
+
releaseClaim,
|
|
31
|
+
claimAge,
|
|
32
|
+
} from '../../../framework/hooks/shared/claim-registry.js';
|
|
20
33
|
|
|
21
34
|
/** Resolve the repo's shared git common dir — where claims live. */
|
|
22
35
|
function gitCommonDirOf(cwd) {
|
|
@@ -25,6 +38,52 @@ function gitCommonDirOf(cwd) {
|
|
|
25
38
|
return common ? resolve(root, common) : null;
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
const norm = (p) => String(p ?? '').replace(/\\/g, '/');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Acquire `feature`'s claim, moving it in the same step if this tree already
|
|
45
|
+
* holds a live claim on a different feature.
|
|
46
|
+
*
|
|
47
|
+
* "This tree's own claim" is identified by PATH, not session id: the CLI has
|
|
48
|
+
* no access to the Claude Code session id SessionStart stamps (that only
|
|
49
|
+
* reaches hooks, via stdin JSON) — path is the one honest signal a manual
|
|
50
|
+
* invocation shares with the automatic claim it may be replacing. Re-using
|
|
51
|
+
* that claim's session id when re-acquiring keeps the move from reading as a
|
|
52
|
+
* takeover of a stranger.
|
|
53
|
+
*/
|
|
54
|
+
function acquireOrMove(feature, { cwd, gitCommonDir, json }) {
|
|
55
|
+
const here = norm(cwd);
|
|
56
|
+
const previousHere = listClaims(gitCommonDir)
|
|
57
|
+
.find((c) => c.live && c.feature !== feature && norm(c.path) === here);
|
|
58
|
+
const takenFrom = foreignClaim(feature, { gitCommonDir, sessionId: previousHere?.sessionId });
|
|
59
|
+
|
|
60
|
+
if (previousHere) releaseClaim(previousHere.feature, { gitCommonDir });
|
|
61
|
+
const identity = previousHere?.sessionId || `cli-${process.pid}`;
|
|
62
|
+
acquireClaim(feature, { gitCommonDir, sessionId: identity, pid: process.pid, path: cwd });
|
|
63
|
+
|
|
64
|
+
if (json) {
|
|
65
|
+
console.log(JSON.stringify({
|
|
66
|
+
feature,
|
|
67
|
+
claimed: true,
|
|
68
|
+
movedFrom: previousHere?.feature ?? null,
|
|
69
|
+
tookOverFrom: takenFrom ? { pid: takenFrom.pid, path: takenFrom.path } : null,
|
|
70
|
+
}));
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(previousHere
|
|
75
|
+
? chalk.green(`\n✅ Claim movido de "${previousHere.feature}" para "${feature}".\n`)
|
|
76
|
+
: chalk.green(`\n✅ Claim de "${feature}" adquirido.\n`));
|
|
77
|
+
if (takenFrom) {
|
|
78
|
+
console.log(chalk.yellow(
|
|
79
|
+
`⚠️ "${feature}" tinha claim VIVO de outra sessão (pid ${takenFrom.pid}` +
|
|
80
|
+
`${takenFrom.path ? ` · ${takenFrom.path}` : ''}, visto há ${claimAge(takenFrom)}) — assumido agora. ` +
|
|
81
|
+
'Confirme com a outra sessão antes de finish/archive/delete.\n'
|
|
82
|
+
));
|
|
83
|
+
}
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
28
87
|
/**
|
|
29
88
|
* @param {string|undefined} feature
|
|
30
89
|
* @param {{ release?: boolean, list?: boolean, json?: boolean, cwd?: string }} [options]
|
|
@@ -61,7 +120,17 @@ export function claimCommand(feature, options = {}) {
|
|
|
61
120
|
process.exit(0);
|
|
62
121
|
}
|
|
63
122
|
|
|
64
|
-
//
|
|
123
|
+
// `morph-spec claim <feature>` (no --release, no --list) is the manual
|
|
124
|
+
// acquire/move half of B1 — automation (SessionStart + heartbeat) covers the
|
|
125
|
+
// common case; this covers switching this session to a different feature
|
|
126
|
+
// mid-task without a release-then-reclaim dance. `--list` forces the
|
|
127
|
+
// read-only view below even when a feature is named.
|
|
128
|
+
if (feature && !options.list) {
|
|
129
|
+
acquireOrMove(feature, { cwd, gitCommonDir, json: options.json });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// No feature (or explicit --list): the read-only board, whole or filtered.
|
|
65
134
|
const all = listClaims(gitCommonDir);
|
|
66
135
|
const rows = feature ? all.filter((c) => c.feature === feature) : all;
|
|
67
136
|
|
|
@@ -67,6 +67,34 @@ function missingPhaseArtifacts(phase, featurePath) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* When a required artifact is absent HERE, check whether it exists in the
|
|
72
|
+
* worktree that owns this feature. `feature.json` travels committed on
|
|
73
|
+
* `morph/{feature}`, so a feature developed in a worktree has its artifacts
|
|
74
|
+
* there and only a scaffold in the primary root — running `advance` from the
|
|
75
|
+
* root then demanded a file that already exists, one directory over.
|
|
76
|
+
*
|
|
77
|
+
* Message-only: this never reads state for a decision and never writes across
|
|
78
|
+
* trees. It replaces a misleading error with an accurate one.
|
|
79
|
+
*
|
|
80
|
+
* Fail-open — no git, no worktree, or any error yields null (today's message).
|
|
81
|
+
*
|
|
82
|
+
* @returns {Promise<{path: string, branch: string|null, present: string[]}|null>}
|
|
83
|
+
*/
|
|
84
|
+
async function artifactsLiveInWorktree(featureName, missing) {
|
|
85
|
+
try {
|
|
86
|
+
const { findWorktreeForFeature } = await import('../project/worktree.js');
|
|
87
|
+
const wt = findWorktreeForFeature(featureName, process.cwd());
|
|
88
|
+
if (!wt?.path) return null;
|
|
89
|
+
|
|
90
|
+
const base = join(wt.path, '.morph', 'features', featureName);
|
|
91
|
+
const present = missing.filter((rel) => existsSync(join(base, rel)));
|
|
92
|
+
return present.length ? { path: wt.path, branch: wt.branch, present } : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
/** Resolve who approved, honestly (mirrors approve.js — no fake human signatures). */
|
|
71
99
|
function resolveApprovedBy(options) {
|
|
72
100
|
if (options.approver) return options.approver;
|
|
@@ -104,6 +132,21 @@ export async function advanceCommand(featureName, options = {}) {
|
|
|
104
132
|
// the defect again.
|
|
105
133
|
const missingArtifacts = missingPhaseArtifacts(phase, featurePath);
|
|
106
134
|
if (missingArtifacts.length && !options.force) {
|
|
135
|
+
// Divergence, not absence: name the tree that actually holds the work
|
|
136
|
+
// instead of asking for a file that exists in it.
|
|
137
|
+
const elsewhere = await artifactsLiveInWorktree(featureName, missingArtifacts);
|
|
138
|
+
if (elsewhere) {
|
|
139
|
+
console.error(chalk.red(`\n❌ A fase "${phase}" de "${featureName}" não pode ser encerrada A PARTIR DESTA ÁRVORE.`));
|
|
140
|
+
console.error(chalk.gray(' Os artefatos não estão ausentes — eles vivem no worktree dono desta feature:'));
|
|
141
|
+
for (const rel of elsewhere.present) console.error(chalk.gray(` • ${rel}`));
|
|
142
|
+
console.error(chalk.gray(`\n Worktree: ${elsewhere.path}${elsewhere.branch ? ` (branch ${elsewhere.branch})` : ''}`));
|
|
143
|
+
console.error(chalk.gray(' Rode o comando LÁ — o gate é gravado no feature.json daquela árvore:'));
|
|
144
|
+
console.error(chalk.cyan(` cd "${elsewhere.path}" && morph-spec advance ${featureName}`));
|
|
145
|
+
console.error(chalk.yellow('\n NÃO use --force aqui: ele carimbaria o gate na cópia da raiz, duplicando'));
|
|
146
|
+
console.error(chalk.yellow(' uma aprovação que a outra árvore já tem e invertendo a ordem dos gates.\n'));
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
107
150
|
console.error(chalk.red(`\n❌ A fase "${phase}" não pode ser encerrada — artefato(s) obrigatório(s) ausente(s):`));
|
|
108
151
|
for (const rel of missingArtifacts) console.error(chalk.gray(` • ${rel}`));
|
|
109
152
|
console.error(chalk.gray('\n Produza o artefato e rode de novo, ou passe --force para avançar assim mesmo.\n'));
|
|
@@ -14,8 +14,16 @@ import { existsSync, chmodSync, readdirSync } from 'fs';
|
|
|
14
14
|
import { homedir } from 'os';
|
|
15
15
|
import { execSync } from 'child_process';
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Current hooks schema version — bump when hook definitions change.
|
|
19
|
+
*
|
|
20
|
+
* 3.5.0 keeps the same registry entries but changes what the installed hooks
|
|
21
|
+
* DO: state is now resolved from the target path's working tree instead of the
|
|
22
|
+
* session's. The hook files are re-copied unconditionally (`copyDirectory`
|
|
23
|
+
* defaults to `overwrite: true`), so the bump does not gate delivery — it makes
|
|
24
|
+
* `morph-spec update` report the change honestly instead of "already current".
|
|
25
|
+
*/
|
|
26
|
+
const HOOKS_VERSION = '3.5.0';
|
|
19
27
|
|
|
20
28
|
/** Marker for old dispatch.js (v1) */
|
|
21
29
|
const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
|