@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.
- package/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/bin/morph-spec.js +22 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +10 -5
- package/framework/MORPH.md +31 -14
- package/framework/agents.json +132 -132
- package/framework/commands/create-umbraco-lp.md +35 -0
- package/framework/commands/migrate-lp-to-umbraco.md +33 -0
- package/framework/commands/morph-archive.md +17 -27
- package/framework/evals/rubrics/README.md +9 -8
- package/framework/hooks/README.md +2 -0
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
- package/framework/hooks/claude-code/core/gate-guard.js +160 -0
- package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
- package/framework/hooks/claude-code/core/state-sync.js +16 -36
- package/framework/hooks/claude-code/core/validate-completion.js +105 -0
- package/framework/hooks/shared/state-reader.js +302 -221
- package/framework/hooks/shared/state-writer.js +124 -0
- package/framework/rules/morph-task-tracking.md +69 -49
- package/framework/rules/morph-workflow.md +192 -191
- package/framework/skills/README.md +1 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +252 -0
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +452 -0
- package/framework/skills/core/morph-apply/SKILL.md +69 -27
- package/framework/skills/core/morph-brainstorming/SKILL.md +6 -2
- package/framework/skills/core/morph-eval/SKILL.md +4 -5
- package/framework/skills/core/morph-implement/SKILL.md +51 -22
- package/framework/skills/core/morph-plan/SKILL.md +10 -4
- package/framework/skills/core/morph-proposal/SKILL.md +8 -7
- package/framework/skills/core/morph-review/SKILL.md +15 -8
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/ai-agents/setup.md +258 -244
- package/framework/templates/REGISTRY.json +1 -3
- package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
- package/framework/templates/context/CONTEXT.md +181 -180
- package/framework/templates/docs/onboarding.md +122 -118
- package/framework/templates/docs/proposal.md +10 -0
- package/framework/templates/docs/spec.md +228 -228
- package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
- package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
- package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
- package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
- package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
- package/package.json +1 -1
- package/src/commands/project/init.js +517 -515
- package/src/commands/project/update.js +415 -368
- package/src/commands/project/worktree.js +212 -210
- package/src/commands/state/advance.js +82 -0
- package/src/commands/state/approve.js +251 -213
- package/src/commands/state/archive.js +111 -0
- package/src/commands/validation/validate-feature.js +244 -79
- package/src/core/state/state-manager.js +256 -74
- package/src/dashboard/scanner/state-scanner.js +35 -8
- package/src/lib/generators/context-generator.js +12 -8
- package/src/lib/risk-detector.js +2 -2
- package/src/utils/file-copier.js +7 -0
- 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,
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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. **
|
|
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
|
-
|
|
20
|
+
morph-spec approval-status {feature}
|
|
31
21
|
```
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
|
20
|
-
|
|
|
21
|
-
|
|
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 <
|
|
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 —
|
|
42
|
+
Ação: [feedback priorizado — alimenta o loop de correção | escalar (só após 3 tentativas)]
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
Quando score ≥
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
//
|
|
148
|
-
//
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 —
|
|
165
|
-
//
|
|
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
|
|
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` +
|