@polymorphism-tech/morph-spec 7.3.0 → 7.5.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 (61) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +22 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +10 -5
  8. package/framework/MORPH.md +31 -14
  9. package/framework/agents.json +132 -132
  10. package/framework/commands/create-umbraco-lp.md +35 -0
  11. package/framework/commands/migrate-lp-to-umbraco.md +33 -0
  12. package/framework/commands/morph-archive.md +17 -27
  13. package/framework/evals/rubrics/README.md +9 -8
  14. package/framework/hooks/README.md +2 -0
  15. package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
  16. package/framework/hooks/claude-code/core/gate-guard.js +160 -0
  17. package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
  18. package/framework/hooks/claude-code/core/state-sync.js +16 -36
  19. package/framework/hooks/claude-code/core/validate-completion.js +105 -0
  20. package/framework/hooks/shared/state-reader.js +302 -221
  21. package/framework/hooks/shared/state-writer.js +124 -0
  22. package/framework/rules/morph-task-tracking.md +69 -49
  23. package/framework/rules/morph-workflow.md +192 -191
  24. package/framework/skills/README.md +1 -1
  25. package/framework/skills/core/create-umbraco-lp/SKILL.md +252 -0
  26. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +452 -0
  27. package/framework/skills/core/morph-apply/SKILL.md +69 -27
  28. package/framework/skills/core/morph-brainstorming/SKILL.md +6 -2
  29. package/framework/skills/core/morph-eval/SKILL.md +4 -5
  30. package/framework/skills/core/morph-implement/SKILL.md +51 -22
  31. package/framework/skills/core/morph-plan/SKILL.md +10 -4
  32. package/framework/skills/core/morph-proposal/SKILL.md +8 -7
  33. package/framework/skills/core/morph-review/SKILL.md +15 -8
  34. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  35. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  36. package/framework/standards/ai-agents/setup.md +258 -244
  37. package/framework/templates/REGISTRY.json +1 -3
  38. package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
  39. package/framework/templates/context/CONTEXT.md +181 -180
  40. package/framework/templates/docs/onboarding.md +122 -118
  41. package/framework/templates/docs/proposal.md +10 -0
  42. package/framework/templates/docs/spec.md +228 -228
  43. package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
  44. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
  45. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
  46. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
  47. package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
  48. package/package.json +1 -1
  49. package/src/commands/project/init.js +517 -515
  50. package/src/commands/project/update.js +415 -368
  51. package/src/commands/project/worktree.js +212 -210
  52. package/src/commands/state/advance.js +82 -0
  53. package/src/commands/state/approve.js +251 -213
  54. package/src/commands/state/archive.js +111 -0
  55. package/src/commands/validation/validate-feature.js +244 -79
  56. package/src/core/state/state-manager.js +256 -74
  57. package/src/dashboard/scanner/state-scanner.js +35 -8
  58. package/src/lib/generators/context-generator.js +12 -8
  59. package/src/lib/risk-detector.js +2 -2
  60. package/src/utils/file-copier.js +7 -0
  61. package/src/utils/hooks-installer.js +527 -516
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Archive a completed MORPH-SPEC feature after Gate 3 approval
3
3
  argument-hint: <feature-name>
4
- allowed-tools: Read, Write, Edit, Bash
4
+ allowed-tools: Read, Bash
5
5
  ---
6
6
 
7
7
  # Archive MORPH Feature
@@ -9,39 +9,29 @@ allowed-tools: Read, Write, Edit, Bash
9
9
  Archive a feature that has cleared Gate 3 (review). Use when the work is shipped and you want the
10
10
  feature out of the active set in `.morph/features/`.
11
11
 
12
- ## Prerequisites
13
-
14
- Before archiving, verify:
15
- - [ ] Feature folder exists at `.morph/features/{feature}/`
16
- - [ ] `4-review/review-report.md` exists and Gate 3 is approved in `state.json`
17
- - [ ] `3-implement/recap.md` is filled in
18
- - [ ] Code is merged or otherwise shipped
19
-
20
- If any precondition fails, stop and report what is missing instead of archiving.
12
+ This is a thin wrapper over the atomic, git-aware CLI command — do **not** move folders or edit state
13
+ by hand. The command moves the folder, stamps the feature `completed` + `archivedAt` in its
14
+ `feature.json`, stages the move in git, and cleans the derivable root index, all-or-nothing.
21
15
 
22
16
  ## Workflow
23
17
 
24
- 1. **Validate completion** by reading `state.json`:
25
- - `features[name].phase === 'review'`
26
- - `features[name].approvalGates.review.approved === true`
27
-
28
- 2. **Move the folder** from active to archive:
18
+ 1. **Verify** the feature cleared Gate 3:
29
19
  ```
30
- .morph/features/{feature}/ → .morph/archive/{feature}/
20
+ morph-spec approval-status {feature}
31
21
  ```
32
- Use `Bash` (`mv` on POSIX, `Move-Item` on Windows). Preserve all phase subfolders
33
- (`0-proposal/`, `1-design/`, `2-plan/`, `3-implement/`, `4-review/`).
34
-
35
- 3. **Update state.json**:
36
- - Set `features[name].status = 'completed'`
37
- - Set `features[name].archivedAt = <ISO timestamp>`
22
+ Gate `review` must be `approved`. If not, stop and report what's missing (don't force it without
23
+ the user's say-so).
38
24
 
39
- 4. **Confirm with the user** before any destructive step. Show the move plan first.
40
-
41
- ## Output
25
+ 2. **Archive** atomically:
26
+ ```
27
+ morph-spec archive {feature}
28
+ ```
29
+ The command refuses if Gate 3 isn't approved (override with `--force` only when the user asks).
30
+ It moves `.morph/features/{feature}/` → `.morph/archive/{feature}/` (via `git mv` when possible),
31
+ sets `status: "completed"` + `archivedAt`, and drops the feature from the thin index.
42
32
 
43
- Present a short summary of what moved and the new path. Do not invent metrics that are not
44
- recorded in state.json or recap.md.
33
+ 3. **Report** the new path (`.morph/archive/{feature}/`). Do not invent metrics not recorded in
34
+ `feature.json` or `recap.md`.
45
35
 
46
36
  ---
47
37
 
@@ -14,11 +14,12 @@ Cada dimensão tem uma rubrica própria. O score final é ponderado.
14
14
 
15
15
  ## Escala de scores
16
16
 
17
- | Score | Ação |
18
- |-------|----------------------------------------------------------------------|
19
- | 8-10 | Aprovado — sem output no contexto, siga para próxima tarefa |
20
- | 6-7.9 | Feedback priorizado — liste o que melhorar, modelo continua |
21
- | < 6 | Escale para revisão humana — relatório detalhado obrigatório |
17
+ | Score | Ação |
18
+ |-------|--------------------------------------------------------------------------------------------|
19
+ | 9-10 | Aprovado — sem output no contexto, siga para próxima tarefa |
20
+ | < 9 | Feedback priorizado — alimenta o loop de correção autônomo do morph-apply (re-implementa, sem pausa humana, até 3 tentativas) |
21
+
22
+ A barra de aprovação por tarefa é **9**. Abaixo disso o modelo corrige sozinho — não escala ao humano entre tarefas. A escalada humana só acontece quando o loop de correção esgota 3 tentativas sem atingir 9 (ver morph-apply/SKILL.md §3a e MORPH.md §0).
22
23
 
23
24
  ## Como calcular o score composto
24
25
 
@@ -28,7 +29,7 @@ score = (arquitetura × 0.30) + (contratos × 0.25) + (qualidade × 0.25) + (tes
28
29
 
29
30
  Arredonde para uma casa decimal.
30
31
 
31
- ## Formato de output quando score < 8
32
+ ## Formato de output quando score < 9
32
33
 
33
34
  Produza exatamente este bloco (não mais, não menos):
34
35
 
@@ -38,7 +39,7 @@ Score: [X.X/10]
38
39
  Issues (ordenados por impacto):
39
40
  - [descrição do problema mais impactante]
40
41
  - [próximo problema]
41
- Ação: [feedback priorizado — continua | escalar para revisão humana]
42
+ Ação: [feedback priorizado — alimenta o loop de correção | escalar (só após 3 tentativas)]
42
43
  ```
43
44
 
44
- Quando score ≥ 8: nenhum output. O silêncio é o sinal de aprovação.
45
+ Quando score ≥ 9: nenhum output. O silêncio é o sinal de aprovação.
@@ -47,6 +47,8 @@ framework/hooks/
47
47
  | **PreToolUse** (Write\|Edit) | protect-spec-files.js | Block | Blocks edits to spec files after approval |
48
48
  | **PreToolUse** (Write\|Edit) | enforce-phase-writes.js | Block | Ensures writes go to current phase directory |
49
49
  | **PreToolUse** (Bash) | _(prompt-type inline guard)_ | Block | Blocks `rm -rf .morph/` and direct state edits via Claude's reasoning |
50
+ | **PostToolUse** (Write\|Edit\|AskUserQuestion) | state-sync.js | State sync | Autonomous state.json updates (feature register, output tracking, gate approval) |
51
+ | **PostToolUse** (Write\|Edit\|AskUserQuestion) | gate-guard.js | Advisory | Warns (never blocks) when a gate action disagrees with the persisted gateDecisions (trust-mode enforcement, issue #63) |
50
52
  | **PostToolUse** (Bash) | dispatch.js | Dispatch | Triggers checkpoints on task completion |
51
53
  | **PostToolUseFailure** | handle-tool-failure.js | Logging | Appends structured JSON to .morph/logs/tool-failures.log |
52
54
  | **Stop** | validate-completion.js | Advisory | Warns about incomplete tasks/missing outputs/pending gates |
@@ -1,90 +1,115 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * PreToolUse Hook: Enforce Phase-Appropriate File Writes
5
- *
6
- * Event: PreToolUse | Matcher: Write|Edit
7
- *
8
- * Ensures writes to .morph/features/{feature}/ go to the current phase's subfolder:
9
- * proposal → only 0-proposal/
10
- * uiux → only 1-design/
11
- * plan → only 2-plan/
12
- * implement → 3-implement/ + any source code (unrestricted)
13
- * review → only 4-review/
14
- *
15
- * Files outside .morph/features/ are always allowed.
16
- *
17
- * Fail-open: exits 0 on any error.
18
- */
19
-
20
- import { existsSync } from 'fs';
21
- import { resolve } from 'path';
22
- import { readStdin } from '../../shared/stdin-reader.js';
23
- import { stateExists, getFeaturePhase } from '../../shared/state-reader.js';
24
- import {
25
- isFeaturePath,
26
- extractFeatureName,
27
- extractPhaseDir,
28
- PHASE_DIRS
29
- } from '../../shared/phase-utils.js';
30
- import { block, pass } from '../../shared/hook-response.js';
31
- import { logHookActivity } from '../../shared/activity-logger.js';
32
- import { getFilePath } from '../../shared/payload-utils.js';
33
-
34
- try {
35
- // Amend mode: bypass phase write enforcement for legitimate corrections
36
- if (process.env.MORPH_AMEND_PHASE) {
37
- console.error(`[morph-spec] AMEND MODE: bypassing phase protection for phase '${process.env.MORPH_AMEND_PHASE}'`);
38
- pass();
39
- }
40
-
41
- if (!stateExists()) pass();
42
-
43
- const payload = await readStdin();
44
- if (!payload) pass();
45
-
46
- const filePath = getFilePath(payload);
47
- if (!filePath) pass();
48
-
49
- // Only check files inside .morph/features/
50
- if (!isFeaturePath(filePath)) pass();
51
-
52
- const featureName = extractFeatureName(filePath);
53
- if (!featureName) pass();
54
-
55
- const phase = getFeaturePhase(featureName);
56
- if (!phase) pass();
57
-
58
- // During implement phase, all writes are allowed (source code is unrestricted)
59
- if (phase === 'implement' || phase === 'sync') pass();
60
-
61
- const targetDir = extractPhaseDir(filePath);
62
- if (!targetDir) pass(); // File directly in feature root — allow
63
-
64
- const allowedDir = PHASE_DIRS[phase];
65
- if (!allowedDir) pass(); // Unknown phase — allow
66
-
67
- if (targetDir !== allowedDir) {
68
- // Allow editing already-existing files from previous phases
69
- const absolutePath = resolve(process.cwd(), filePath);
70
- if (existsSync(absolutePath)) {
71
- logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
72
- pass(); // editing existing file — allow
73
- }
74
-
75
- const phaseLabel = phase.toUpperCase();
76
- logHookActivity('enforce-phase-writes', 'PreToolUse', 'blocked');
77
- block(
78
- `MORPH-SPEC: Writing to '${targetDir}/' is not allowed during ${phaseLabel} phase.\n` +
79
- `Current phase '${phase}' only permits writes to '${allowedDir}/'.\n\n` +
80
- `If you need to write to a different phase folder, ensure the feature\n` +
81
- `has advanced to the appropriate phase first (check gates in state.json).`
82
- );
83
- }
84
-
85
- logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
86
- pass();
87
- } catch {
88
- // Fail-open
89
- process.exit(0);
90
- }
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PreToolUse Hook: Enforce Phase-Appropriate File Writes
5
+ *
6
+ * Event: PreToolUse | Matcher: Write|Edit
7
+ *
8
+ * Ensures writes to .morph/features/{feature}/ go to the current phase's subfolder:
9
+ * proposal → only 0-proposal/
10
+ * uiux → only 1-design/
11
+ * plan → only 2-plan/
12
+ * implement → 3-implement/ + any source code (unrestricted)
13
+ * review → only 4-review/
14
+ *
15
+ * Files outside .morph/features/ are always allowed.
16
+ *
17
+ * Fail-open: exits 0 on any error.
18
+ */
19
+
20
+ import { existsSync } from 'fs';
21
+ import { resolve } from 'path';
22
+ import { readStdin } from '../../shared/stdin-reader.js';
23
+ import { stateExists, getFeaturePhase, isGateApproved } from '../../shared/state-reader.js';
24
+ import {
25
+ isFeaturePath,
26
+ extractFeatureName,
27
+ extractPhaseDir,
28
+ PHASE_DIRS
29
+ } from '../../shared/phase-utils.js';
30
+
31
+ /**
32
+ * The gate that authorizes writing a phase folder's FIRST artifact. Once that
33
+ * gate is approved, the next phase's canonical file may be created even though
34
+ * the phase only advances (via derivation) once that file exists — this is the
35
+ * fix for the forward-transition chicken-and-egg. proposal-approval unlocks both
36
+ * the optional `1-design/` and `2-plan/` (the uiux fork).
37
+ */
38
+ const PHASE_DIR_ENTRY_GATE = {
39
+ '1-design': 'proposal',
40
+ '2-plan': 'proposal',
41
+ '3-implement': 'plan',
42
+ '4-review': 'plan',
43
+ };
44
+ import { block, pass } from '../../shared/hook-response.js';
45
+ import { logHookActivity } from '../../shared/activity-logger.js';
46
+ import { getFilePath } from '../../shared/payload-utils.js';
47
+
48
+ try {
49
+ // Amend mode: bypass phase write enforcement for legitimate corrections
50
+ if (process.env.MORPH_AMEND_PHASE) {
51
+ console.error(`[morph-spec] AMEND MODE: bypassing phase protection for phase '${process.env.MORPH_AMEND_PHASE}'`);
52
+ pass();
53
+ }
54
+
55
+ if (!stateExists()) pass();
56
+
57
+ const payload = await readStdin();
58
+ if (!payload) pass();
59
+
60
+ const filePath = getFilePath(payload);
61
+ if (!filePath) pass();
62
+
63
+ // Only check files inside .morph/features/
64
+ if (!isFeaturePath(filePath)) pass();
65
+
66
+ const featureName = extractFeatureName(filePath);
67
+ if (!featureName) pass();
68
+
69
+ const phase = getFeaturePhase(featureName);
70
+ if (!phase) pass();
71
+
72
+ // During implement phase, all writes are allowed (source code is unrestricted)
73
+ if (phase === 'implement' || phase === 'sync') pass();
74
+
75
+ const targetDir = extractPhaseDir(filePath);
76
+ if (!targetDir) pass(); // File directly in feature root — allow
77
+
78
+ const allowedDir = PHASE_DIRS[phase];
79
+ if (!allowedDir) pass(); // Unknown phase — allow
80
+
81
+ if (targetDir !== allowedDir) {
82
+ // Allow editing already-existing files from previous phases
83
+ const absolutePath = resolve(process.cwd(), filePath);
84
+ if (existsSync(absolutePath)) {
85
+ logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
86
+ pass(); // editing existing file — allow
87
+ }
88
+
89
+ // Gate-aware forward transition: allow creating the NEXT phase's first
90
+ // artifact as soon as the gate authorizing that phase is approved. Without
91
+ // this, the first file of a new phase can never be written (the phase only
92
+ // advances once that file exists) — the deadlock this hook used to create.
93
+ const entryGate = PHASE_DIR_ENTRY_GATE[targetDir];
94
+ if (entryGate && isGateApproved(featureName, entryGate)) {
95
+ logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
96
+ pass();
97
+ }
98
+
99
+ const phaseLabel = phase.toUpperCase();
100
+ logHookActivity('enforce-phase-writes', 'PreToolUse', 'blocked');
101
+ block(
102
+ `MORPH-SPEC: Writing to '${targetDir}/' is not allowed during ${phaseLabel} phase.\n` +
103
+ `Current phase '${phase}' only permits writes to '${allowedDir}/'.\n\n` +
104
+ `To move forward, approve the current gate and scaffold the next phase in\n` +
105
+ `one step: morph-spec advance ${featureName}\n` +
106
+ `(this approves the gate that unlocks the next phase folder).`
107
+ );
108
+ }
109
+
110
+ logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
111
+ pass();
112
+ } catch {
113
+ // Fail-open
114
+ process.exit(0);
115
+ }
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gate-guard.js — PostToolUse hook (detect-and-warn, never block)
4
+ *
5
+ * Closes the gap from issue #63: the trust-mode contract lived only in skill
6
+ * pseudocode, with nothing verifying the LLM actually honored it. This hook
7
+ * compares the OBSERVED gate action against the decision the skill persisted
8
+ * to state.json (`features[feature].gateDecisions[gate]`) and injects a
9
+ * non-blocking warning when they disagree.
10
+ *
11
+ * It is a silent guardrail, NOT an orchestrator (per MORPH.md): it never
12
+ * blocks, never writes state, and fails open on any error or missing data.
13
+ *
14
+ * Two mismatch directions:
15
+ * - LLM PAUSED (AskUserQuestion on a gate) but the persisted decision said
16
+ * pause:false → over-pausing under trust:auto. Warn.
17
+ * - LLM AUTO-APPROVED (gate became approvedBy:"auto") but the persisted
18
+ * decision said pause:true → skipped a required human pause. Warn.
19
+ *
20
+ * Coherent action, no persisted decision, or any error → silence.
21
+ */
22
+
23
+ import { readFileSync } from 'fs';
24
+ import { loadState, stateExists } from '../../shared/state-reader.js';
25
+ import { injectContext, pass } from '../../shared/hook-response.js';
26
+
27
+ function readStdin() {
28
+ try {
29
+ return JSON.parse(readFileSync(0, 'utf8') || '{}');
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ function projectDir() {
36
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
37
+ }
38
+
39
+ function findActiveFeature(state) {
40
+ if (!state.features) return null;
41
+ const entries = Object.entries(state.features);
42
+ const active = entries.find(([, f]) => f.status === 'in_progress');
43
+ if (active) return active[0];
44
+ return entries[0]?.[0] ?? null;
45
+ }
46
+
47
+ /** Map a gate question's text to a gate id. Mirrors state-sync.js. */
48
+ function gateFromQuestion(question) {
49
+ if (typeof question !== 'string') return null;
50
+ const lower = question.toLowerCase();
51
+ if (lower.includes('gate 1') || lower.includes('proposta')) return 'proposal';
52
+ if (lower.includes('gate 2') || lower.includes('plano') || lower.includes('plan')) return 'plan';
53
+ if (lower.includes('gate 3') || lower.includes('review')) return 'review';
54
+ return null;
55
+ }
56
+
57
+ function getDecision(state, feature, gate) {
58
+ const d = state.features?.[feature]?.gateDecisions?.[gate];
59
+ if (!d || typeof d.pause !== 'boolean') return null;
60
+ return d;
61
+ }
62
+
63
+ /**
64
+ * The LLM just asked a gate question. If the persisted decision said this
65
+ * gate should auto-pass (pause:false), the human was interrupted needlessly.
66
+ */
67
+ function checkAskUserQuestion(input, state, feature) {
68
+ // Questions live in tool_input.questions[]; answers in tool_response.
69
+ const questions = input.tool_input?.questions;
70
+ const askedTexts = [];
71
+ if (Array.isArray(questions)) {
72
+ for (const q of questions) {
73
+ if (typeof q?.question === 'string') askedTexts.push(q.question);
74
+ if (typeof q?.header === 'string') askedTexts.push(q.header);
75
+ }
76
+ }
77
+ // Fallback: some payloads only surface answered question keys.
78
+ const answers = input.tool_response?.answers ?? input.tool_response?.responses;
79
+ if (answers && typeof answers === 'object') {
80
+ askedTexts.push(...Object.keys(answers));
81
+ }
82
+
83
+ for (const text of askedTexts) {
84
+ const gate = gateFromQuestion(text);
85
+ if (!gate) continue;
86
+ const decision = getDecision(state, feature, gate);
87
+ if (!decision) continue; // no persisted decision → nothing to verify
88
+ if (decision.pause === false) {
89
+ return injectContext(
90
+ `⚠️ [morph-spec] GATE OVER-PAUSE — você apresentou o Gate "${gate}" via AskUserQuestion, ` +
91
+ `mas a decisão registrada em state.json (gateDecisions.${gate}) era pause:false ` +
92
+ `(trust:${decision.trust ?? 'auto'}, nenhum sinal de risco). ` +
93
+ `Sob trust:auto sem risco, este gate deveria auto-passar com um summary curto, sem interromper o humano. ` +
94
+ `Se a pausa foi intencional (você detectou um risco que o registro não capturou), atualize gateDecisions.${gate}.signals antes de prosseguir; ` +
95
+ `caso contrário, evite pausar gates auto-aprováveis.`
96
+ );
97
+ }
98
+ }
99
+ return pass();
100
+ }
101
+
102
+ /**
103
+ * The LLM just edited state.json. If a gate flipped to approvedBy:"auto" but
104
+ * the persisted decision said pause:true, a required human pause was skipped.
105
+ */
106
+ function checkStateEdit(input, state, feature) {
107
+ const filePath = String(input.tool_input?.file_path ?? '').replace(/\\/g, '/');
108
+ if (!filePath.endsWith('.morph/state.json')) return pass();
109
+
110
+ const gates = state.features?.[feature]?.approvalGates ?? {};
111
+ for (const gate of ['proposal', 'plan', 'review']) {
112
+ const g = gates[gate];
113
+ const autoApproved = g?.approved === true && g?.approvedBy === 'auto';
114
+ if (!autoApproved) continue;
115
+ const decision = getDecision(state, feature, gate);
116
+ if (!decision) continue;
117
+ if (decision.pause === true) {
118
+ return injectContext(
119
+ `⚠️ [morph-spec] GATE SKIPPED PAUSE — o Gate "${gate}" foi marcado approvedBy:"auto" em state.json, ` +
120
+ `mas a decisão registrada (gateDecisions.${gate}) era pause:true ` +
121
+ `(sinal: ${decision.reason || 'risco detectado'}). ` +
122
+ `Um sinal de risco exigia pausa humana via AskUserQuestion — uma auto-aprovação aqui ignora o trust mode. ` +
123
+ `Reabra o gate com AskUserQuestion (label "aprovado, prosseguir") ou corrija o sinal de risco antes de avançar.`
124
+ );
125
+ }
126
+ }
127
+ return pass();
128
+ }
129
+
130
+ function main() {
131
+ const cwd = projectDir();
132
+ if (!stateExists(cwd)) return pass();
133
+
134
+ const input = readStdin();
135
+ const toolName = input.tool_name;
136
+
137
+ // Short-circuit before loading state: a Write/Edit only matters if it touched
138
+ // state.json. Skip the disk read for the dozens of unrelated edits per feature.
139
+ if (toolName === 'Edit' || toolName === 'Write') {
140
+ const fp = String(input.tool_input?.file_path ?? '').replace(/\\/g, '/');
141
+ if (!fp.endsWith('.morph/state.json')) return pass();
142
+ } else if (toolName !== 'AskUserQuestion') {
143
+ return pass();
144
+ }
145
+
146
+ try {
147
+ const state = loadState(cwd);
148
+ if (!state) return pass();
149
+ const feature = findActiveFeature(state);
150
+ if (!feature) return pass();
151
+
152
+ if (toolName === 'AskUserQuestion') return checkAskUserQuestion(input, state, feature);
153
+ if (toolName === 'Edit' || toolName === 'Write') return checkStateEdit(input, state, feature);
154
+ } catch {
155
+ // fail-open
156
+ }
157
+ return pass();
158
+ }
159
+
160
+ main();
@@ -11,11 +11,14 @@
11
11
  *
12
12
  * Semantic exception for files in MUTABLE_SPEC_FIELDS (e.g. tasks.json):
13
13
  * the plan contract is frozen, but per-task progress fields (status, outputs,
14
- * notes) may still change during the implement phase. For those files a Write
15
- * is allowed only when the semantic diff touches solely the mutable fields;
16
- * Edit is always blocked because the legitimate progress path is a full Write
17
- * via writeTasksJson(). This lets the implement phase record task status
18
- * without unfreezing the plan.
14
+ * notes) may still change during the implement phase. For those files BOTH a
15
+ * full Write and a targeted Edit are allowed — but only when the semantic diff
16
+ * touches solely the mutable fields. The Edit is simulated (old_string →
17
+ * new_string applied to the current file) and diffed exactly like a Write, so a
18
+ * one-field status flip via Edit is permitted while any change to the frozen
19
+ * plan contract (titles, dependencies, doneCriteria, the task set) is blocked.
20
+ * This is the sanctioned progress path — the agent never has to rewrite the
21
+ * whole file nor reach for a Node script to record task status.
19
22
  *
20
23
  * Fail-open: exits 0 on any error.
21
24
  */
@@ -129,40 +132,39 @@ try {
129
132
  // that touches only those fields. Anything else stays blocked.
130
133
  if (mutableFields) {
131
134
  const toolName = getToolName(payload);
132
- const newContent = payload?.tool_input?.content;
133
-
134
- // Edit is never the legitimate progress path — progress is written as a
135
- // full file via writeTasksJson(). Block Edit; fall through to the message.
136
- if (toolName !== 'Write' || typeof newContent !== 'string') {
137
- logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
138
- block(
139
- `MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
140
- `Progress updates (${mutableFields.join(', ')}) must be written as a full-file\n` +
141
- `Write, not an Edit. The plan contract (titles, dependencies, doneCriteria…)\n` +
142
- `is frozen. To change the plan itself, ask the user to revoke the\n` +
143
- `'${requiredGate}' gate in .morph/state.json, then re-present it for approval.`
144
- );
145
- }
146
135
 
147
- // Semantic diff: old = current file on disk, new = proposed content.
148
- // Any failure to read/parse → fail-open (pass), never a false block.
136
+ // Build the proposed document from either a full Write or a targeted Edit,
137
+ // then semantic-diff it against the current file on disk. Any failure to
138
+ // read / parse / apply → fail-open (pass), never a false block.
139
+ const absPath = isAbsolute(filePath) ? filePath : join(process.cwd(), filePath);
149
140
  let oldDoc, newDoc;
150
141
  try {
151
- // filePath may already be absolute (Claude Code usually sends absolute
152
- // paths); only join against cwd when it is relative.
153
- const absPath = isAbsolute(filePath) ? filePath : join(process.cwd(), filePath);
154
- oldDoc = JSON.parse(readFileSync(absPath, 'utf-8'));
155
- newDoc = JSON.parse(newContent);
142
+ const original = readFileSync(absPath, 'utf-8');
143
+ oldDoc = JSON.parse(original);
144
+
145
+ if (toolName === 'Edit') {
146
+ // Simulate the edit: apply old_string → new_string to the current file.
147
+ const oldStr = payload?.tool_input?.old_string;
148
+ const newStr = payload?.tool_input?.new_string;
149
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') throw new Error('no edit strings');
150
+ const edited = payload?.tool_input?.replace_all
151
+ ? original.split(oldStr).join(newStr)
152
+ : original.replace(oldStr, newStr);
153
+ newDoc = JSON.parse(edited);
154
+ } else {
155
+ const newContent = payload?.tool_input?.content;
156
+ if (typeof newContent !== 'string') throw new Error('no content');
157
+ newDoc = JSON.parse(newContent);
158
+ }
156
159
  } catch {
157
- // Cannot compare — fail-open rather than block a possibly-valid write.
158
160
  logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
159
161
  pass();
160
162
  }
161
163
 
162
164
  const violations = diffPlanContract(oldDoc, newDoc, mutableFields);
163
165
  if (violations.length === 0) {
164
- // Only mutable progress fields changed — this is a legitimate
165
- // implement-phase update.
166
+ // Only mutable progress fields changed — a legitimate implement-phase
167
+ // update via Write OR Edit.
166
168
  logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
167
169
  pass();
168
170
  }
@@ -170,7 +172,7 @@ try {
170
172
  logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
171
173
  block(
172
174
  `MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
173
- `This Write changes the frozen plan contract:\n` +
175
+ `This ${toolName} changes the frozen plan contract:\n` +
174
176
  violations.map(v => ` • ${v}`).join('\n') + '\n\n' +
175
177
  `During the implement phase only these fields may change: ${mutableFields.join(', ')}.\n` +
176
178
  `To change the plan itself, ask the user to revoke the '${requiredGate}' gate\n` +