@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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +9 -0
  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 +46 -43
  7. package/framework/MORPH.md +120 -168
  8. package/framework/commands/morph-hotfix.md +1 -1
  9. package/framework/docs/CLI.md +41 -0
  10. package/framework/hooks/shared/phase-utils.js +0 -2
  11. package/framework/phases.json +1 -1
  12. package/framework/rules/morph-workflow.md +1 -1
  13. package/framework/schemas/feature.schema.json +12 -0
  14. package/framework/skills/README.md +1 -1
  15. package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -1
  16. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -1
  17. package/framework/skills/core/morph-apply/SKILL.md +12 -20
  18. package/framework/skills/core/morph-brainstorming/SKILL.md +11 -32
  19. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  20. package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
  21. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  22. package/framework/skills/core/morph-plan/SKILL.md +13 -27
  23. package/framework/skills/core/morph-proposal/SKILL.md +3 -15
  24. package/framework/skills/core/morph-review/SKILL.md +15 -8
  25. package/framework/skills/core/morph-stack-scan/SKILL.md +10 -26
  26. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  27. package/package.json +1 -1
  28. package/src/commands/project/doctor.js +11 -4
  29. package/src/commands/project/init.js +1 -1
  30. package/src/commands/project/status.js +2 -2
  31. package/src/commands/project/update.js +91 -7
  32. package/src/commands/state/gate-check.js +223 -0
  33. package/src/commands/state/gate-decision.js +46 -10
  34. package/src/commands/validation/verify.js +18 -1
  35. package/src/core/paths/output-schema.js +0 -1
  36. package/src/lib/agents/output-collector.js +0 -1
  37. package/src/lib/git/default-branch.js +65 -0
  38. package/src/lib/risk-detector.js +13 -0
  39. package/src/lib/stack-filter.js +7 -2
  40. package/src/scripts/setup-infra.js +27 -8
  41. package/src/utils/skills-installer.js +28 -15
  42. 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
+ }
@@ -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
  }
@@ -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].split(',').map(s => s.trim()).filter(Boolean);
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. Copy framework/templates → .morph/framework/templates ---
268
- log('Step 5: Copying framework templates...');
269
- const frameworkTemplatesSrc = join(FRAMEWORK_DIR, 'templates');
270
- const templatesDest = join(frameworkDestDir, 'templates');
271
- if (await pathExists(frameworkTemplatesSrc)) {
272
- await copyDirectory(frameworkTemplatesSrc, templatesDest);
273
- } else {
274
- await ensureDir(templatesDest);
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
- // Cleanup orphan morph-* skill dirs that exist in source but were filtered out
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.startsWith('morph-')) continue;
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: installed.size };
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
- }