@polymorphism-tech/morph-spec 8.2.0 → 8.3.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 (54) hide show
  1. package/README.md +70 -99
  2. package/bin/morph-spec.js +13 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +76 -135
  5. package/docs/QUICKSTART.md +35 -51
  6. package/framework/CLAUDE.md +3 -2
  7. package/framework/MORPH.md +20 -2
  8. package/framework/agents.json +3 -1
  9. package/framework/hooks/README.md +3 -0
  10. package/framework/hooks/claude-code/core/protect-spec-files.js +7 -1
  11. package/framework/hooks/claude-code/core/telemetry-log.js +102 -0
  12. package/framework/hooks/shared/build-check.js +8 -1
  13. package/framework/hooks/shared/payload-utils.js +16 -2
  14. package/framework/hooks/shared/phase-utils.js +2 -0
  15. package/framework/hooks/shared/telemetry-logger.js +75 -0
  16. package/framework/rules/ai-agents-standards.md +1 -0
  17. package/framework/schemas/feature.schema.json +0 -5
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +12 -7
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  24. package/framework/skills/core/morph-implement/SKILL.md +22 -10
  25. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  26. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  27. package/framework/skills/core/morph-review/SKILL.md +19 -12
  28. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  29. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  30. package/framework/standards/STANDARDS.json +43 -1
  31. package/framework/standards/ai-agents/observability-patterns.md +156 -0
  32. package/framework/standards/ai-agents/production.md +3 -47
  33. package/framework/templates/REGISTRY.json +0 -93
  34. package/package.json +1 -1
  35. package/src/commands/observability/telemetry.js +75 -0
  36. package/src/commands/project/worktree.js +150 -36
  37. package/src/commands/state/score.js +13 -2
  38. package/src/commands/tasks/dag.js +11 -3
  39. package/src/core/paths/output-schema.js +28 -0
  40. package/src/core/state/state-manager.js +0 -128
  41. package/src/dashboard/app/pages/LiveState.jsx +0 -43
  42. package/src/lib/detectors/design-system-detector.js +0 -43
  43. package/src/lib/generators/index.js +0 -1
  44. package/src/lib/stack/stack-profile.js +40 -9
  45. package/src/lib/tasks/tasks-json-parser.js +21 -0
  46. package/src/lib/tasks/test-runner.js +26 -4
  47. package/src/lib/telemetry/telemetry-reader.js +103 -0
  48. package/src/lib/validators/validation-runner.js +13 -74
  49. package/src/utils/hooks-installer.js +27 -3
  50. package/framework/templates/context/CONTEXT-FEATURE.md +0 -269
  51. package/framework/templates/context/CONTEXT.md +0 -181
  52. package/src/lib/agents/metrics-aggregator.js +0 -56
  53. package/src/lib/agents/metrics-calculator.js +0 -125
  54. package/src/lib/generators/metadata-extractor.js +0 -385
@@ -51,24 +51,55 @@ function readStack(cwd) {
51
51
  }
52
52
  }
53
53
 
54
+ /** Merge two or more profiles' validator lists (deduped) and boolean flags. */
55
+ function mergeProfiles(profiles) {
56
+ return {
57
+ isDotnet: profiles.some((p) => p.isDotnet),
58
+ isNextjs: profiles.some((p) => p.isNextjs),
59
+ defaultValidators: [...new Set(profiles.flatMap((p) => p.defaultValidators))],
60
+ checkpointValidators: [...new Set(profiles.flatMap((p) => p.checkpointValidators))],
61
+ buildCommand: profiles.map((p) => p.buildCommand).find(Boolean) ?? null,
62
+ };
63
+ }
64
+
54
65
  /**
55
66
  * Get the StackProfile for the current project.
67
+ *
68
+ * `stack` may be a compound value (e.g. "dotnet-nextjs", "dotnet+nextjs") —
69
+ * the framework's own convention for monorepos (see stack-filter.js's
70
+ * getProjectTags, which splits project.stack the same way). Each segment is
71
+ * resolved independently and matching profiles are merged, instead of the
72
+ * whole compound string failing every exact-match check and silently
73
+ * collapsing to the empty profile (0 default validators).
56
74
  */
57
75
  export function getStackProfile(cwd, stackOverride) {
58
76
  const root = cwd ?? process.cwd();
59
77
  const raw = stackOverride ?? readStack(root);
60
78
  const stack = raw?.toLowerCase() ?? 'unknown';
61
79
 
62
- let profile;
63
- if (NEXTJS_STACKS.has(stack)) {
64
- profile = PROFILES.nextjs;
65
- } else if (NO_VALIDATOR_STACKS.has(stack)) {
66
- profile = PROFILES.empty;
67
- } else if (DOTNET_STACKS.has(stack) || stack === 'unknown') {
68
- profile = PROFILES.dotnet;
69
- } else {
70
- profile = PROFILES.empty;
80
+ const segments = stack.split(/[-+]/).filter(Boolean);
81
+
82
+ // Single-segment stack (the overwhelming common case): resolve exactly as
83
+ // before, byte-for-byte, including the 'unknown' → dotnet fallback.
84
+ if (segments.length <= 1) {
85
+ let profile;
86
+ if (NEXTJS_STACKS.has(stack)) {
87
+ profile = PROFILES.nextjs;
88
+ } else if (NO_VALIDATOR_STACKS.has(stack)) {
89
+ profile = PROFILES.empty;
90
+ } else if (DOTNET_STACKS.has(stack) || stack === 'unknown') {
91
+ profile = PROFILES.dotnet;
92
+ } else {
93
+ profile = PROFILES.empty;
94
+ }
95
+ return { stack, ...profile };
71
96
  }
72
97
 
98
+ // Compound stack: resolve each recognized segment and merge.
99
+ const matched = [];
100
+ if (segments.some((s) => DOTNET_STACKS.has(s))) matched.push(PROFILES.dotnet);
101
+ if (segments.some((s) => NEXTJS_STACKS.has(s))) matched.push(PROFILES.nextjs);
102
+
103
+ const profile = matched.length > 0 ? mergeProfiles(matched) : PROFILES.empty;
73
104
  return { stack, ...profile };
74
105
  }
@@ -98,6 +98,27 @@ export async function parseTasksJson(filePath) {
98
98
  return data;
99
99
  }
100
100
 
101
+ /**
102
+ * Lenient variant for read-only analysis commands (e.g. `morph-spec dag`):
103
+ * a missing `version` field is defaulted to "1.0" instead of failing
104
+ * validation — legacy tasks.json predating the field is still analyzable.
105
+ * All other schema errors still throw exactly like `parseTasksJson`.
106
+ *
107
+ * @param {string} filePath - Absolute path to tasks.json
108
+ * @returns {Promise<{ data: object, versionDefaulted: boolean }>}
109
+ */
110
+ export async function parseTasksJsonLenient(filePath) {
111
+ const data = await fs.readJson(filePath);
112
+ const versionDefaulted = data.version === undefined;
113
+ if (versionDefaulted) data.version = '1.0';
114
+
115
+ const { valid, errors } = validateTasksJson(data);
116
+ if (!valid) {
117
+ throw new Error(`Invalid tasks.json:\n ${errors.join('\n ')}`);
118
+ }
119
+ return { data, versionDefaulted };
120
+ }
121
+
101
122
  /**
102
123
  * Synchronous variant — used by hooks.
103
124
  */
@@ -4,13 +4,16 @@
4
4
  * Resolution order for test command:
5
5
  * 1. .morph/config/config.json → project.testCommand
6
6
  * 2. .sln or .csproj present at project root → "dotnet test"
7
- * 3. package.json present AND has scripts.test → "npm test"
8
- * 4. null (no test suite detected)
7
+ * 3. Nested *Tests.csproj anywhere in the tree (monorepo without a root
8
+ * .sln) → "dotnet test <proj> --nologo -v q" for each match
9
+ * 4. package.json present AND has scripts.test → "npm test"
10
+ * 5. null (no test suite detected)
9
11
  */
10
12
 
11
13
  import { execSync } from 'child_process';
12
14
  import { existsSync, readFileSync, readdirSync } from 'fs';
13
15
  import { join } from 'path';
16
+ import { globSync } from 'glob';
14
17
 
15
18
  /**
16
19
  * Resolve the test command for a project.
@@ -46,7 +49,26 @@ export function resolveTestCommand(projectPath = '.') {
46
49
  // Unreadable project directory — fall through to next detection
47
50
  }
48
51
 
49
- // 3. package.json present AND has scripts.test → npm test
52
+ // 3. No root-level project file — look for a nested test project
53
+ // (e.g. tests/MyApp.Tests/MyApp.Tests.csproj), common in monorepos that
54
+ // have no root .sln. Run each matched project explicitly rather than a
55
+ // bare `dotnet test` (which finds nothing to build at the root).
56
+ try {
57
+ const testProjects = globSync('**/*Tests.csproj', {
58
+ cwd: projectPath,
59
+ ignore: ['**/bin/**', '**/obj/**', '**/node_modules/**'],
60
+ });
61
+ if (testProjects.length > 0) {
62
+ return testProjects
63
+ .sort()
64
+ .map((p) => `dotnet test "${p}" --nologo -v q`)
65
+ .join(' && ');
66
+ }
67
+ } catch {
68
+ // Glob failure — fall through to next detection
69
+ }
70
+
71
+ // 4. package.json present AND has scripts.test → npm test
50
72
  const packageJsonPath = join(projectPath, 'package.json');
51
73
  if (existsSync(packageJsonPath)) {
52
74
  try {
@@ -59,7 +81,7 @@ export function resolveTestCommand(projectPath = '.') {
59
81
  }
60
82
  }
61
83
 
62
- // 4. Nothing found
84
+ // 5. Nothing found
63
85
  return null;
64
86
  }
65
87
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Telemetry aggregation — reads the append-only harness event stream
3
+ * (.morph/logs/events.jsonl, written by the telemetry-log hook) and summarizes
4
+ * the whole execution path: which skills ran, which personas were dispatched,
5
+ * which standards (knowledge base) were read, the tool mix, and the prompts.
6
+ *
7
+ * Pure aggregation (`aggregateTelemetry`) is separated from disk I/O
8
+ * (`loadTelemetry`) so the summary logic is unit-testable without fixtures —
9
+ * the same split fleet-scanner uses. CLI-side; runs in the target project cwd.
10
+ */
11
+
12
+ import { readFileSync, existsSync } from 'fs';
13
+ import { join } from 'path';
14
+
15
+ const EVENTS_FILE = join('.morph', 'logs', 'events.jsonl');
16
+
17
+ /**
18
+ * Read the raw event stream from a project root. A corrupt/truncated line is
19
+ * skipped, not fatal. Returns [] when there is no log.
20
+ *
21
+ * @param {string} [projectPath]
22
+ * @returns {Object[]}
23
+ */
24
+ export function loadTelemetry(projectPath) {
25
+ try {
26
+ const p = join(projectPath || process.cwd(), EVENTS_FILE);
27
+ if (!existsSync(p)) return [];
28
+ const events = [];
29
+ for (const line of readFileSync(p, 'utf-8').split('\n')) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed) continue;
32
+ try { events.push(JSON.parse(trimmed)); } catch { /* skip corrupt line */ }
33
+ }
34
+ return events;
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ function rank(map) {
41
+ return [...map.entries()]
42
+ .map(([name, count]) => ({ name, count }))
43
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
44
+ }
45
+
46
+ /**
47
+ * Summarize an event stream.
48
+ *
49
+ * @param {Object[]} events - raw events (from loadTelemetry)
50
+ * @param {{ feature?: string }} [opts] - optional feature filter
51
+ * @returns {{
52
+ * total: number,
53
+ * skills: Array<{name:string,count:number}>,
54
+ * personas: Array<{name:string,count:number}>,
55
+ * standards: Array<{path:string,count:number}>,
56
+ * tools: Array<{name:string,count:number}>,
57
+ * prompts: Array<{ts:string,source:'user'|'agent',feature:?string,phase:?string,text:string}>,
58
+ * features: string[]
59
+ * }}
60
+ */
61
+ export function aggregateTelemetry(events, opts = {}) {
62
+ const scoped = opts.feature
63
+ ? (events || []).filter(e => e.feature === opts.feature)
64
+ : (events || []);
65
+
66
+ const skills = new Map();
67
+ const personas = new Map();
68
+ const standards = new Map();
69
+ const tools = new Map();
70
+ const features = new Set();
71
+ const prompts = [];
72
+
73
+ for (const e of scoped) {
74
+ if (e.tool) tools.set(e.tool, (tools.get(e.tool) || 0) + 1);
75
+ if (e.feature) features.add(e.feature);
76
+
77
+ if (e.tool === 'Skill' && e.skill) {
78
+ skills.set(e.skill, (skills.get(e.skill) || 0) + 1);
79
+ } else if ((e.tool === 'Agent' || e.tool === 'Task') && e.persona) {
80
+ personas.set(e.persona, (personas.get(e.persona) || 0) + 1);
81
+ }
82
+
83
+ if (e.kind === 'standard' && e.target) {
84
+ standards.set(e.target, (standards.get(e.target) || 0) + 1);
85
+ }
86
+
87
+ if (e.tool === 'UserPrompt' && typeof e.prompt === 'string') {
88
+ prompts.push({ ts: e.ts, source: 'user', feature: e.feature ?? null, phase: e.phase ?? null, text: e.prompt });
89
+ } else if ((e.tool === 'Agent' || e.tool === 'Task') && typeof e.prompt === 'string' && e.prompt) {
90
+ prompts.push({ ts: e.ts, source: 'agent', feature: e.feature ?? null, phase: e.phase ?? null, text: e.prompt });
91
+ }
92
+ }
93
+
94
+ return {
95
+ total: scoped.length,
96
+ skills: rank(skills),
97
+ personas: rank(personas),
98
+ standards: rank(standards).map(({ name, count }) => ({ path: name, count })),
99
+ tools: rank(tools),
100
+ prompts,
101
+ features: [...features],
102
+ };
103
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * MORPH-SPEC Validation Runner
3
3
  *
4
- * Unified orchestrator that runs the right validators based on the feature's
5
- * active agents and decisions. Called by task-done before marking tasks complete.
4
+ * Unified orchestrator that runs the right validators based on the project's
5
+ * stack profile. Called by task-done before marking tasks complete.
6
6
  *
7
7
  * MORPH-SPEC 3.0 - Phase A: Active Validation Pipeline
8
8
  */
@@ -10,16 +10,10 @@
10
10
  import { existsSync, readFileSync } from 'fs';
11
11
  import { join } from 'path';
12
12
  import chalk from 'chalk';
13
- import { loadState } from '../../core/state/state-manager.js';
14
13
  import { getAbsoluteOutputPath, resolveContractsOutputType } from '../../core/paths/output-schema.js';
15
14
  import { getStackProfile } from '../stack/stack-profile.js';
16
15
  import { aggregateScores, loadRubric } from './score-aggregator.js';
17
16
 
18
- /** Validators that only make sense for .NET projects. */
19
- const DOTNET_ONLY_VALIDATORS = new Set([
20
- 'architecture', 'packages', 'contract-compliance'
21
- ]);
22
-
23
17
  /**
24
18
  * Heavy validators skipped in 'light' mode (checkpoint-only).
25
19
  * Light mode runs only spec, content, and stack-specific lightweight validators.
@@ -29,31 +23,6 @@ const HEAVY_VALIDATORS = new Set([
29
23
  'css', 'design-system'
30
24
  ]);
31
25
 
32
- /**
33
- * Load agent → validators map from agents.json (data-driven)
34
- * @param {string} projectPath - Root path of the project
35
- * @returns {Object} Map of agent IDs to validator arrays
36
- */
37
- function loadAgentValidatorMap(projectPath) {
38
- try {
39
- const agentsPath = join(projectPath, '.morph/framework/agents.json');
40
- const agentsConfig = JSON.parse(readFileSync(agentsPath, 'utf8'));
41
-
42
- const map = {};
43
- Object.entries(agentsConfig.agents || {}).forEach(([agentId, agent]) => {
44
- if (!agentId.startsWith('_comment') && agent.validators && agent.validators.length > 0) {
45
- map[agentId] = agent.validators;
46
- }
47
- });
48
-
49
- return map;
50
- } catch (err) {
51
- console.warn(chalk.yellow('⚠️ Failed to load agents.json - no validators will run'));
52
- console.warn(chalk.gray(` Run 'morph-spec init' to set up MORPH-SPEC configuration`));
53
- return {}; // Empty map = honest failure
54
- }
55
- }
56
-
57
26
  /**
58
27
  * Run all relevant validators for a feature
59
28
  *
@@ -61,7 +30,7 @@ function loadAgentValidatorMap(projectPath) {
61
30
  * @param {string} featureName - Feature name from state.json
62
31
  * @param {Object} options - Options
63
32
  * @param {boolean} options.verbose - Show detailed output
64
- * @param {string[]} options.validators - Specific validators to run (overrides agent-based detection)
33
+ * @param {string[]} options.validators - Specific validators to run (overrides stack-based detection)
65
34
  * @returns {Object} { passed, errors, warnings, issues, summary }
66
35
  */
67
36
  export async function runValidation(projectPath, featureName, options = {}) {
@@ -81,7 +50,7 @@ export async function runValidation(projectPath, featureName, options = {}) {
81
50
  };
82
51
 
83
52
  // Determine which validators to run
84
- const validatorIds = options.validators || detectValidators(featureName, projectPath);
53
+ const validatorIds = options.validators || detectValidators(projectPath);
85
54
 
86
55
  // Filter by mode: 'light' skips heavy validators, 'full' (default) runs all
87
56
  const mode = options.mode || 'full';
@@ -98,17 +67,8 @@ export async function runValidation(projectPath, featureName, options = {}) {
98
67
  validatorIds.push(...constraints.enabledValidators);
99
68
 
100
69
  if (validatorIds.length === 0) {
101
- // Distinguish: no agents vs. no validators mapped
102
- const state = loadState(false);
103
- const activeAgents = state?.features?.[featureName]?.activeAgents || [];
104
- if (activeAgents.length === 0) {
105
- result.summary = 'No validators ran — feature has no active agents';
106
- console.log(chalk.gray('\n ℹ️ No validators configured (no active agents in state.json)'));
107
- console.log(chalk.gray(' Add agents: morph-spec state add-agent ' + featureName + ' <agent-id>'));
108
- } else {
109
- result.summary = `No validators mapped for active agents: ${activeAgents.join(', ')}`;
110
- console.log(chalk.gray(`\n ℹ️ Agents [${activeAgents.join(', ')}] have no validators in agents.json`));
111
- }
70
+ result.summary = 'No validators ran — no validators configured for this stack';
71
+ console.log(chalk.gray('\n ℹ️ No validators configured for this stack'));
112
72
  result._skipReason = result.summary;
113
73
  return result;
114
74
  }
@@ -124,7 +84,10 @@ export async function runValidation(projectPath, featureName, options = {}) {
124
84
  const _profileForContracts = getStackProfile(projectPath);
125
85
  const contractsType = resolveContractsOutputType(_profileForContracts.stack);
126
86
  const contractsPath = getAbsoluteOutputPath(projectPath, featureName, contractsType);
127
- if (existsSync(contractsPath) && !_profileForContracts.isNextjs) {
87
+ // Positive check on isDotnet (contract-compliance is a VSA/.NET concern) —
88
+ // NOT `!isNextjs`, which used to wrongly suppress it for a compound
89
+ // "dotnet-nextjs" stack that has a .NET backend but is ALSO isNextjs.
90
+ if (existsSync(contractsPath) && _profileForContracts.isDotnet) {
128
91
  validatorIds.push('contract-compliance');
129
92
  }
130
93
  }
@@ -188,37 +151,13 @@ export async function runValidation(projectPath, featureName, options = {}) {
188
151
  }
189
152
 
190
153
  /**
191
- * Detect which validators to run based on feature's active agents
192
- * @param {string} featureName - Feature name
154
+ * Detect which validators to run, driven by the project's stack profile.
193
155
  * @param {string} projectPath - Project root path (default: current directory)
194
156
  * @returns {string[]} Array of validator IDs to run
195
157
  */
196
- function detectValidators(featureName, projectPath = '.') {
158
+ function detectValidators(projectPath = '.') {
197
159
  const profile = getStackProfile(projectPath);
198
- const state = loadState(false);
199
-
200
- if (!state || !state.features[featureName]) {
201
- return [...profile.defaultValidators]; // stack-aware defaults
202
- }
203
-
204
- const feature = state.features[featureName];
205
- const agents = feature.activeAgents || [];
206
- const validatorSet = new Set(profile.defaultValidators); // stack defaults first
207
-
208
- const agentValidatorMap = loadAgentValidatorMap(projectPath);
209
- for (const agentId of agents) {
210
- const validators = agentValidatorMap[agentId] || [];
211
- validators.forEach(v => validatorSet.add(v));
212
- }
213
-
214
- // Filter out .NET-only validators for non-.NET projects
215
- if (!profile.isDotnet) {
216
- for (const v of DOTNET_ONLY_VALIDATORS) {
217
- validatorSet.delete(v);
218
- }
219
- }
220
-
221
- return Array.from(validatorSet);
160
+ return [...profile.defaultValidators];
222
161
  }
223
162
 
224
163
  /**
@@ -15,7 +15,7 @@ import { homedir } from 'os';
15
15
  import { execSync } from 'child_process';
16
16
 
17
17
  /** Current hooks schema version — bump when hook definitions change */
18
- const HOOKS_VERSION = '3.3.0';
18
+ const HOOKS_VERSION = '3.4.0';
19
19
 
20
20
  /** Marker for old dispatch.js (v1) */
21
21
  const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
@@ -165,13 +165,37 @@ const MORPH_HOOKS = [
165
165
  ]
166
166
  },
167
167
 
168
- // === PostToolUse: All — loop detection ===
168
+ // === PostToolUse: All — telemetry capture (silent) + loop detection ===
169
+ // telemetry-log is the single generic vantage point of an NLH: it appends
170
+ // every tool call (skill, sub-agent dispatch, knowledge-base read, …) to
171
+ // .morph/logs/events.jsonl. Silent and fail-open — never blocks, never
172
+ // injects. `morph-spec telemetry` reads the stream back.
169
173
  {
170
174
  event: 'PostToolUse',
171
175
  matcher: null,
176
+ hooks: [
177
+ {
178
+ type: 'command',
179
+ command: 'node framework/hooks/claude-code/core/telemetry-log.js',
180
+ timeout: 5000
181
+ },
182
+ {
183
+ type: 'command',
184
+ command: 'node framework/hooks/claude-code/core/loop-detect.js',
185
+ timeout: 5000
186
+ }
187
+ ]
188
+ },
189
+
190
+ // === UserPromptSubmit — telemetry capture of the user's prompt (silent) ===
191
+ // The only vantage point on the user's own prompt; completes the execution
192
+ // path alongside sub-agent prompts captured on PostToolUse.
193
+ {
194
+ event: 'UserPromptSubmit',
195
+ matcher: null,
172
196
  hooks: [{
173
197
  type: 'command',
174
- command: 'node framework/hooks/claude-code/core/loop-detect.js',
198
+ command: 'node framework/hooks/claude-code/core/telemetry-log.js',
175
199
  timeout: 5000
176
200
  }]
177
201
  },