@polymorphism-tech/morph-spec 8.17.0 → 8.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/morph-spec.js +9 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +46 -43
- package/framework/MORPH.md +120 -168
- package/framework/commands/morph-hotfix.md +1 -1
- package/framework/docs/CLI.md +41 -0
- package/framework/hooks/shared/phase-utils.js +0 -2
- package/framework/phases.json +1 -1
- package/framework/rules/morph-workflow.md +1 -1
- package/framework/schemas/feature.schema.json +12 -0
- package/framework/skills/README.md +1 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -1
- package/framework/skills/core/morph-apply/SKILL.md +12 -20
- package/framework/skills/core/morph-brainstorming/SKILL.md +11 -32
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +13 -27
- package/framework/skills/core/morph-proposal/SKILL.md +3 -15
- package/framework/skills/core/morph-review/SKILL.md +15 -8
- package/framework/skills/core/morph-stack-scan/SKILL.md +10 -26
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +11 -4
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/status.js +2 -2
- package/src/commands/project/update.js +91 -7
- package/src/commands/state/gate-check.js +223 -0
- package/src/commands/state/gate-decision.js +46 -10
- package/src/commands/validation/verify.js +18 -1
- package/src/core/paths/output-schema.js +0 -1
- package/src/lib/agents/output-collector.js +0 -1
- package/src/lib/git/default-branch.js +65 -0
- package/src/lib/risk-detector.js +13 -0
- package/src/lib/stack-filter.js +7 -2
- package/src/scripts/setup-infra.js +27 -8
- package/src/utils/skills-installer.js +28 -15
- package/src/lib/standards/digest-builder.js +0 -72
|
@@ -71,3 +71,68 @@ export function hasOriginRemote(cwd = process.cwd()) {
|
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve HEAD's full commit SHA. Fail-open: null when cwd isn't a git repo,
|
|
77
|
+
* or the repo has no commits yet (unborn HEAD) — never throws.
|
|
78
|
+
*
|
|
79
|
+
* Shared by `morph-spec verify` (the `verification` stamp) and
|
|
80
|
+
* `morph-spec gate-check` (signals + staleness-vs-code checks) — both need
|
|
81
|
+
* the exact commit a result covers, mirroring the same fail-open pattern
|
|
82
|
+
* `reviewStaleness()` in approve.js uses for the same `git rev-parse HEAD` call.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} [cwd]
|
|
85
|
+
* @returns {string|null}
|
|
86
|
+
*/
|
|
87
|
+
export function getHeadSha(cwd = process.cwd()) {
|
|
88
|
+
try {
|
|
89
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse `git diff --shortstat` output ("3 files changed, 45 insertions(+), 2
|
|
97
|
+
* deletions(-)") into counts. Any line missing (e.g. only insertions, no
|
|
98
|
+
* deletions) contributes 0 for that field. Empty input (no diff) is all-zero,
|
|
99
|
+
* not an error.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} text
|
|
102
|
+
* @returns {{ filesChanged: number, insertions: number, deletions: number }}
|
|
103
|
+
*/
|
|
104
|
+
function parseShortstat(text) {
|
|
105
|
+
const files = /(\d+) files? changed/.exec(text || '');
|
|
106
|
+
const ins = /(\d+) insertions?\(\+\)/.exec(text || '');
|
|
107
|
+
const del = /(\d+) deletions?\(-\)/.exec(text || '');
|
|
108
|
+
return {
|
|
109
|
+
filesChanged: files ? Number(files[1]) : 0,
|
|
110
|
+
insertions: ins ? Number(ins[1]) : 0,
|
|
111
|
+
deletions: del ? Number(del[1]) : 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `git diff --shortstat <baseRef>` against the CURRENT WORKING TREE (tracked
|
|
117
|
+
* edits — staged, unstaged, and everything committed since baseRef —
|
|
118
|
+
* deliberately not `baseRef...HEAD`, so a gate-check mid-implement still sees
|
|
119
|
+
* uncommitted work). Same as plain `git diff`, this does NOT count brand-new
|
|
120
|
+
* UNTRACKED files (never `git add`-ed) — a real, known gap if a gate check
|
|
121
|
+
* runs before `git add`; not fixed here since covering it needs `--no-index`
|
|
122
|
+
* or `git add -N` bookkeeping this shared helper doesn't own. Fail-open: null
|
|
123
|
+
* when `baseRef` is falsy, cwd isn't a git repo, or the diff itself fails
|
|
124
|
+
* (e.g. baseRef doesn't resolve).
|
|
125
|
+
*
|
|
126
|
+
* @param {string|null} baseRef - typically `detectDefaultBranch(cwd)`
|
|
127
|
+
* @param {string} [cwd]
|
|
128
|
+
* @returns {{ filesChanged: number, insertions: number, deletions: number }|null}
|
|
129
|
+
*/
|
|
130
|
+
export function getDiffStat(baseRef, cwd = process.cwd()) {
|
|
131
|
+
if (!baseRef) return null;
|
|
132
|
+
try {
|
|
133
|
+
const out = execFileSync('git', ['diff', '--shortstat', baseRef], { cwd, stdio: 'pipe' }).toString().trim();
|
|
134
|
+
return parseShortstat(out);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Signals checked (in order):
|
|
9
9
|
* - designSystemDivergence: boolean — conflicting design sources detected
|
|
10
10
|
* - standardsViolations: string[] — violations of mandate.md proibições
|
|
11
|
+
* - verificationStatus: string — último `morph-spec verify` ('fail' pausa)
|
|
11
12
|
* - evalScore: number — morph-eval score (pause if < 9)
|
|
12
13
|
* - taskCount: number — total tasks (pause if > 20)
|
|
13
14
|
*/
|
|
@@ -52,6 +53,17 @@ export function shouldPauseGate(config, signals) {
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// Verify vermelho no momento do gate = a barreira determinística (build/
|
|
57
|
+
// testes/validadores) está falhando — código antes do juiz vale também
|
|
58
|
+
// para a decisão de pausa. Só 'fail' pausa: ausência de stamp não é sinal
|
|
59
|
+
// (features antigas não têm o campo).
|
|
60
|
+
if (signals.verificationStatus === 'fail') {
|
|
61
|
+
return {
|
|
62
|
+
pause: true,
|
|
63
|
+
reason: 'último morph-spec verify está VERMELHO — rode verify e corrija antes de aprovar o gate',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
|
|
56
68
|
return {
|
|
57
69
|
pause: true,
|
|
@@ -80,6 +92,7 @@ export function getRiskSignals(signals) {
|
|
|
80
92
|
if (Array.isArray(signals.standardsViolations) && signals.standardsViolations.length > 0) {
|
|
81
93
|
out.push(`standards violations: ${signals.standardsViolations.join(', ')}`);
|
|
82
94
|
}
|
|
95
|
+
if (signals.verificationStatus === 'fail') out.push('verify vermelho');
|
|
83
96
|
if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
|
|
84
97
|
out.push(`eval score ${signals.evalScore}/10`);
|
|
85
98
|
}
|
package/src/lib/stack-filter.js
CHANGED
|
@@ -16,10 +16,15 @@ export function parseStacks(content) {
|
|
|
16
16
|
|
|
17
17
|
const fm = fmMatch[1];
|
|
18
18
|
|
|
19
|
-
// Try inline format: stacks: [dotnet, blazor]
|
|
19
|
+
// Try inline format: stacks: [dotnet, blazor] — quotes stripped like the
|
|
20
|
+
// list branch below; `["umbraco"]` sem isso parseava como a string literal
|
|
21
|
+
// `"umbraco"` (com aspas) e nunca casava tag nenhuma.
|
|
20
22
|
const inlineMatch = fm.match(/^stacks:\s*\[([^\]]*)\]/m);
|
|
21
23
|
if (inlineMatch) {
|
|
22
|
-
const items = inlineMatch[1]
|
|
24
|
+
const items = inlineMatch[1]
|
|
25
|
+
.split(',')
|
|
26
|
+
.map(s => s.replace(/["']/g, '').trim())
|
|
27
|
+
.filter(Boolean);
|
|
23
28
|
return items.length > 0 ? items : ['*'];
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -264,14 +264,23 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
264
264
|
await writeFile(contextReadmePath, readmeContent);
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
// --- 5.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
267
|
+
// --- 5. framework/templates is intentionally NOT copied to
|
|
268
|
+
// .morph/framework/templates anymore (R3 perf plan, Task 2). Template
|
|
269
|
+
// resolution is layered (template-registry.js, architecture-scanner.js
|
|
270
|
+
// resolveTemplatePath): a project-local .morph/framework/templates/{path}
|
|
271
|
+
// is still checked FIRST as an explicit override, then falls back to the
|
|
272
|
+
// npm package's own framework/templates/ — which is what every template
|
|
273
|
+
// actually rendered from until now anyway, since the installed copy was
|
|
274
|
+
// never read as anything but that override. Pre-populating it was a
|
|
275
|
+
// byte-for-byte redundant 348KB copy on every install/update.
|
|
276
|
+
log('Step 5: Skipping framework templates copy (resolved from package; local override still supported)...');
|
|
277
|
+
// Cópia legada de instalação pré-R3: se existir, ela SOMBREIA os templates
|
|
278
|
+
// do pacote (override-first). O `morph-spec update` reconcilia (remove cópia
|
|
279
|
+
// stale por fronteira de versão); aqui só avisamos — init não conhece a
|
|
280
|
+
// versão anterior do projeto para decidir com segurança.
|
|
281
|
+
const legacyTemplatesDir = join(frameworkDestDir, 'templates');
|
|
282
|
+
if (await pathExists(legacyTemplatesDir)) {
|
|
283
|
+
log(' ⚠ .morph/framework/templates/ existe (instalação antiga) e tem precedência sobre os templates do pacote — rode `morph-spec update` para reconciliar, ou apague se não for customização sua.');
|
|
275
284
|
}
|
|
276
285
|
|
|
277
286
|
// --- 6. Copy framework/standards → .morph/framework/standards ---
|
|
@@ -324,6 +333,16 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
324
333
|
await copyFile(morphMdSrc, morphMdDest);
|
|
325
334
|
}
|
|
326
335
|
|
|
336
|
+
// --- 8b2. Copy framework/docs/CLI.md → .morph/framework/CLI.md ---
|
|
337
|
+
// Referência completa dos comandos (flags, semântica, exemplos) — o
|
|
338
|
+
// CLAUDE.md residente carrega só a tabela compacta e aponta para cá.
|
|
339
|
+
log('Step 8b2: Copying CLI.md (command reference)...');
|
|
340
|
+
const cliMdSrc = join(FRAMEWORK_DIR, 'docs', 'CLI.md');
|
|
341
|
+
const cliMdDest = join(frameworkDestDir, 'CLI.md');
|
|
342
|
+
if (await pathExists(cliMdSrc)) {
|
|
343
|
+
await copyFile(cliMdSrc, cliMdDest);
|
|
344
|
+
}
|
|
345
|
+
|
|
327
346
|
// --- 8c. Copy framework/phases.json → .morph/framework/phases.json ---
|
|
328
347
|
log('Step 8c: Copying phases.json...');
|
|
329
348
|
const phasesSrc = join(FRAMEWORK_DIR, 'phases.json');
|
|
@@ -75,8 +75,14 @@ function copyDirectory(src, dest) {
|
|
|
75
75
|
*
|
|
76
76
|
* @param {string} srcDir - Source directory to walk
|
|
77
77
|
* @param {string} destDir - Destination base directory (.claude/skills/)
|
|
78
|
+
* @param {string[]} projectTags - Stack tags from config
|
|
79
|
+
* @param {Set<string>} installedNow - Accumulator: skill names this call
|
|
80
|
+
* actually (re)installed. NOT derived by re-scanning destDir afterwards —
|
|
81
|
+
* a disk scan can't distinguish "installed this run" from "leftover from a
|
|
82
|
+
* previous run with different projectTags", which is exactly the stale
|
|
83
|
+
* entry a stack change needs cleaned up.
|
|
78
84
|
*/
|
|
79
|
-
function installSkillsFromDir(srcDir, destDir, projectTags) {
|
|
85
|
+
function installSkillsFromDir(srcDir, destDir, projectTags, installedNow) {
|
|
80
86
|
const entries = readdirSync(srcDir);
|
|
81
87
|
for (const entry of entries) {
|
|
82
88
|
const srcPath = join(srcDir, entry);
|
|
@@ -90,9 +96,10 @@ function installSkillsFromDir(srcDir, destDir, projectTags) {
|
|
|
90
96
|
if (!shouldInstallSkill(content, projectTags)) continue;
|
|
91
97
|
const skillDestDir = join(destDir, entry);
|
|
92
98
|
copyDirectory(srcPath, skillDestDir);
|
|
99
|
+
installedNow.add(entry);
|
|
93
100
|
} else {
|
|
94
101
|
// Level/category directory — recurse
|
|
95
|
-
installSkillsFromDir(srcPath, destDir, projectTags);
|
|
102
|
+
installSkillsFromDir(srcPath, destDir, projectTags, installedNow);
|
|
96
103
|
}
|
|
97
104
|
} else if (entry.endsWith('.md') && entry !== 'README.md') {
|
|
98
105
|
// Legacy flat .md skill — check stack filter before copying
|
|
@@ -102,6 +109,7 @@ function installSkillsFromDir(srcDir, destDir, projectTags) {
|
|
|
102
109
|
const skillDir = join(destDir, skillName);
|
|
103
110
|
mkdirSync(skillDir, { recursive: true });
|
|
104
111
|
copyFileSync(srcPath, join(skillDir, 'SKILL.md'));
|
|
112
|
+
installedNow.add(skillName);
|
|
105
113
|
}
|
|
106
114
|
}
|
|
107
115
|
}
|
|
@@ -147,28 +155,33 @@ export async function installSkills(projectDir, { projectTags = [] } = {}) {
|
|
|
147
155
|
const claudeSkillsDir = join(projectDir, '.claude', 'skills');
|
|
148
156
|
mkdirSync(claudeSkillsDir, { recursive: true });
|
|
149
157
|
|
|
158
|
+
// Skill names this call actually (re)installs, given projectTags. Tracked
|
|
159
|
+
// as we go (NOT by re-scanning claudeSkillsDir afterwards) — a disk scan
|
|
160
|
+
// can't tell "installed this run" apart from "leftover from a previous
|
|
161
|
+
// run with different projectTags", which is exactly the stale entry a
|
|
162
|
+
// stack change (or a re-run that narrows the stack) needs cleaned up.
|
|
163
|
+
const installedNow = new Set();
|
|
150
164
|
for (const level of SKILL_LEVELS_TO_INSTALL) {
|
|
151
165
|
const levelDir = join(FRAMEWORK_SKILLS_DIR, level);
|
|
152
166
|
if (!existsSync(levelDir)) continue;
|
|
153
|
-
installSkillsFromDir(levelDir, claudeSkillsDir, projectTags);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Collect installed morph-* skill names
|
|
157
|
-
const installed = new Set();
|
|
158
|
-
for (const entry of readdirSync(claudeSkillsDir)) {
|
|
159
|
-
if (existsSync(join(claudeSkillsDir, entry, 'SKILL.md'))) {
|
|
160
|
-
installed.add(entry);
|
|
161
|
-
}
|
|
167
|
+
installSkillsFromDir(levelDir, claudeSkillsDir, projectTags, installedNow);
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
//
|
|
170
|
+
// Clean-slate: remove any skill dir that is a KNOWN framework skill (still
|
|
171
|
+
// present in framework/skills/core/) but did NOT get (re)installed this
|
|
172
|
+
// run — i.e. it was filtered out by a `stacks:` mismatch (project.stack
|
|
173
|
+
// changed, or a re-run narrowed the tags). Previously scoped to `morph-`
|
|
174
|
+
// prefixed names only, which silently exempted every non-`morph-` skill
|
|
175
|
+
// (the Umbraco pair) from ever being cleaned up: install once with
|
|
176
|
+
// `umbraco` in the stack, drop the stack later, and they'd survive every
|
|
177
|
+
// reinstall/update forever. Names outside allSourceSkills (user-added,
|
|
178
|
+
// non-framework skills) are never touched.
|
|
165
179
|
const allSourceSkills = collectSourceSkillNames();
|
|
166
180
|
for (const entry of readdirSync(claudeSkillsDir)) {
|
|
167
|
-
if (!entry.
|
|
168
|
-
if (!installed.has(entry) && allSourceSkills.has(entry)) {
|
|
181
|
+
if (!installedNow.has(entry) && allSourceSkills.has(entry)) {
|
|
169
182
|
rmSync(join(claudeSkillsDir, entry), { recursive: true, force: true });
|
|
170
183
|
}
|
|
171
184
|
}
|
|
172
185
|
|
|
173
|
-
return { installed:
|
|
186
|
+
return { installed: installedNow.size };
|
|
174
187
|
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Validator Prompt Builder
|
|
3
|
-
*
|
|
4
|
-
* Generates read-only validation task prompts for Tier-4 validator agents
|
|
5
|
-
* from agents.json hook_behavior configuration.
|
|
6
|
-
*
|
|
7
|
-
* Used by dispatch-agents.js in --mode validate.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { readFileSync, existsSync } from 'fs';
|
|
11
|
-
import { join, dirname } from 'path';
|
|
12
|
-
import { fileURLToPath } from 'url';
|
|
13
|
-
|
|
14
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
|
|
16
|
-
const AGENTS_JSON_PATH = join(__dirname, '../../../framework/agents.json');
|
|
17
|
-
|
|
18
|
-
let _agentsCache = null;
|
|
19
|
-
|
|
20
|
-
/** Load and cache agents.json */
|
|
21
|
-
function loadAgents() {
|
|
22
|
-
if (_agentsCache) return _agentsCache;
|
|
23
|
-
if (!existsSync(AGENTS_JSON_PATH)) return { agents: {} };
|
|
24
|
-
try {
|
|
25
|
-
_agentsCache = JSON.parse(readFileSync(AGENTS_JSON_PATH, 'utf8'));
|
|
26
|
-
return _agentsCache;
|
|
27
|
-
} catch {
|
|
28
|
-
return { agents: {} };
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Build a read-only validation task prompt for Tier-4 validator agents.
|
|
34
|
-
* Uses hook_behavior.validates[] from agents.json.
|
|
35
|
-
*
|
|
36
|
-
* @param {string} agentId - Agent ID (must be tier 4 with hook_behavior)
|
|
37
|
-
* @param {string} [taskId] - Optional task ID being validated
|
|
38
|
-
* @returns {string} Validator task prompt or ''
|
|
39
|
-
*/
|
|
40
|
-
export function buildValidatorPrompt(agentId, taskId) {
|
|
41
|
-
const agents = loadAgents();
|
|
42
|
-
const agentData = agents.agents?.[agentId];
|
|
43
|
-
if (!agentData?.hook_behavior) return '';
|
|
44
|
-
|
|
45
|
-
const validates = agentData.hook_behavior.validates || [];
|
|
46
|
-
const severity = agentData.hook_behavior.severity || 'error';
|
|
47
|
-
const blocksOnFail = agentData.hook_behavior.blocks_on_fail ?? true;
|
|
48
|
-
const taskSuffix = taskId ? ` for task ${taskId}` : '';
|
|
49
|
-
|
|
50
|
-
const lines = [
|
|
51
|
-
`You are ${agentData.title || agentId} (Tier 4 Validator). Mode: READ-ONLY validation${taskSuffix}.`,
|
|
52
|
-
`DO NOT edit any files. Review the current implementation for the following violations:`,
|
|
53
|
-
``,
|
|
54
|
-
`Checks (severity: ${severity}):`,
|
|
55
|
-
...validates.map(v => ` - ${v}`),
|
|
56
|
-
``,
|
|
57
|
-
`Output a single JSON line: { "passed": boolean, "issues": [{ "file": "path/to/file", "line": 0, "message": "description", "rule": "rule-name" }] }`,
|
|
58
|
-
blocksOnFail
|
|
59
|
-
? `BLOCKING: if any issues found, implementation must be fixed before task can complete.`
|
|
60
|
-
: `NON-BLOCKING: issues are warnings only — task completion is not blocked.`,
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
return lines.join('\n');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Invalidate module-level caches.
|
|
68
|
-
* Used in tests to ensure a fresh load of agents.json.
|
|
69
|
-
*/
|
|
70
|
-
export function clearCache() {
|
|
71
|
-
_agentsCache = null;
|
|
72
|
-
}
|