@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
@@ -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.12.0"
7
+ cliVersion: "8.15.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.12.0"
7
+ cliVersion: "8.15.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.12.0"
7
+ cliVersion: "8.15.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.12.0"
7
+ cliVersion: "8.15.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.12.0"
7
+ cliVersion: "8.15.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.12.0"
7
+ cliVersion: "8.15.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), a resposta textual "aprovado" do usuário é registrada pelo hook `state-sync` como `approvedBy: "manual"` — nunca `"auto"`.
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 `approvedBy: "manual"` em `feature.json` (autoritativo) — nunca `"auto"`
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.12.0"
7
+ cliVersion: "8.15.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.12.0"
7
+ cliVersion: "8.15.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.12.0"
7
+ cliVersion: "8.15.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.12.0"
7
+ cliVersion: "8.15.0"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -45,17 +45,19 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
45
45
  4. **Despache o avaliador independente** — o score do Gate 3 nunca é auto-atribuído por quem implementou:
46
46
  - Rode `morph-spec verify {feature} --json` (escopo feature) e capture o JSON — é a saída real de build+testes+validadores que você anexa ao prompt do avaliador independente (`morph-evaluator`).
47
47
  - Gere o **diff da implementação** (`git diff` do range da feature, ou a lista de arquivos do recap.md com seus conteúdos atuais).
48
- - Despache o subagent registrado `morph-evaluator` (gerado de `agents.json` — a persona do avaliador é o system prompt dele, com tools read-only), com contexto fresco:
48
+ - Despache o subagent registrado `morph-evaluator` (gerado de `agents.json` — a persona do avaliador é o system prompt dele, com Read/Glob/Grep + Bash somente para verificação, sem Write/Edit), com contexto fresco:
49
49
  ```
50
50
  dispatch Agent (subagent_type: "morph-evaluator") with prompt:
51
51
  contexto: mandate.md + spec.md + diff da implementação + saída real dos testes
52
52
  instrução: |
53
- Você NÃO participou da implementação — avalie apenas o que está no diff,
54
- contra o mandate. Aplique as rubricas em .morph/framework/evals/rubrics/
55
- (architecture 30%, contracts 25%, code-quality 25%, test-coverage 20%)
56
- seguindo as bandas 0-10 de cada uma.
53
+ Você NÃO participou da implementação — avalie o que está no diff,
54
+ contra o mandate. Você tem Bash: use `morph-spec verify`, testes/build
55
+ do stack e `git log/diff/show` para conferir por conta própria o que
56
+ foi reportado, em vez de confiar cegamente. Aplique as rubricas em
57
+ .morph/framework/evals/rubrics/ (architecture 30%, contracts 25%,
58
+ code-quality 25%, test-coverage 20%) seguindo as bandas 0-10 de cada uma.
57
59
  NÃO reescreva código. NÃO toque em tasks.json, feature.json nem no board
58
- de sessão.
60
+ de sessão — Bash é só para verificação, nunca para mudar estado.
59
61
  Contrato do relatório final: GRAVE seu relatório em
60
62
  `.morph/features/{feature}/4-review/evaluator-report.md` ANTES de
61
63
  encerrar — esse arquivo é o valor de retorno autoritativo desta
@@ -124,7 +126,7 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
124
126
  **Recomendação:** Aprovado
125
127
  ```
126
128
 
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.
129
+ 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
130
 
129
131
  **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
132
 
@@ -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.12.0"
7
+ cliVersion: "8.15.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.12.0"
7
+ cliVersion: "8.15.0"
8
8
  ---
9
9
 
10
10
  # morph-standards
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.12.0",
3
+ "version": "8.15.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -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 case automation cannot cover: a session that died without
8
- * releasing, leaving a claim that keeps warning everyone until its TTL expires.
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
- * Read-only by default (`--list`); the only mutation is `--release`.
11
- * Always exits 0 except on a genuine misuse — the claim system is advisory and
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 { listClaims, releaseClaim, claimAge } from '../../../framework/hooks/shared/claim-registry.js';
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
- // Default and --list are the same read: the whole board, or one feature.
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'));